diff --git a/.github/workflows/build-macos-arm.yml b/.github/workflows/build-macos-arm.yml new file mode 100644 index 00000000..7baf5ef4 --- /dev/null +++ b/.github/workflows/build-macos-arm.yml @@ -0,0 +1,139 @@ +name: Build macOS App (Apple Silicon) + +on: + push: + branches: + - "**" + tags: + - "*" + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS Bundle (Apple Silicon) + runs-on: macos-14 + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + 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="v0.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 Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: requirements.txt + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.8.1 + with: + pixi-version: v0.48.2 + cache: true + cache-key: ${{ github.sha }}-macos-arm + + - name: Install macOS dependencies + run: bash scripts/mac_setup.sh --install + + - name: Lint + run: pixi run lint + + - name: Test + run: pixi run test + + - name: Build macOS artifacts + run: | + printf "4\n" | bash scripts/mac_build.sh --version "${{ env.VERSION }}" + + - name: Package .app bundle + 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-${{ steps.set-version.outputs.version }}-macos-arm-app.zip" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_NAME" + + - name: Rename DMG + run: | + DMG_PATH="dist/Rayforge_${{ env.VERSION }}.dmg" + if [ ! -f "$DMG_PATH" ]; then + echo "DMG not found at $DMG_PATH" + exit 1 + fi + OUT_DMG="rayforge-${{ steps.set-version.outputs.version }}-macos-arm.dmg" + mv "$DMG_PATH" "$OUT_DMG" + + - name: Upload macOS bundle artifact + uses: actions/upload-artifact@v4 + with: + name: rayforge-${{ steps.set-version.outputs.version }}-macos-arm-app.zip + path: rayforge-${{ steps.set-version.outputs.version }}-macos-arm-app.zip + compression-level: 9 + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: rayforge-${{ steps.set-version.outputs.version }}-macos-arm-dmg + path: rayforge-${{ steps.set-version.outputs.version }}-macos-arm.dmg + + release: + name: Create GitHub Release (Apple Silicon) + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && github.repository == 'barebaric/rayforge' + permissions: + contents: write + steps: + - name: Download macOS app artifact + uses: actions/download-artifact@v4 + with: + name: rayforge-${{ needs.build.outputs.version }}-macos-arm-app.zip + + - name: Download DMG artifact + uses: actions/download-artifact@v4 + with: + name: rayforge-${{ needs.build.outputs.version }}-macos-arm-dmg + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + rayforge-${{ needs.build.outputs.version }}-macos-arm-app.zip + rayforge-${{ needs.build.outputs.version }}-macos-arm.dmg + draft: false + prerelease: false + name: Release ${{ needs.build.outputs.version }} + tag_name: ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml new file mode 100644 index 00000000..5de554ae --- /dev/null +++ b/.github/workflows/build-macos-intel.yml @@ -0,0 +1,139 @@ +name: Build macOS App (Intel) + +on: + push: + branches: + - "**" + tags: + - "*" + pull_request: + branches: + - "**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS Bundle (Intel) + runs-on: macos-13 + outputs: + version: ${{ steps.set-version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + 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="v0.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 Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: requirements.txt + + - name: Install Pixi + uses: prefix-dev/setup-pixi@v0.8.1 + with: + pixi-version: v0.48.2 + cache: true + cache-key: ${{ github.sha }}-macos-intel + + - name: Install macOS dependencies + run: bash scripts/mac_setup.sh --install + + - name: Lint + run: pixi run lint + + - name: Test + run: pixi run test + + - name: Build macOS artifacts + run: | + printf "4\n" | bash scripts/mac_build.sh --version "${{ env.VERSION }}" + + - name: Package .app bundle + 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-${{ steps.set-version.outputs.version }}-macos-intel-app.zip" + ditto -c -k --keepParent "$APP_PATH" "$ZIP_NAME" + + - name: Rename DMG + run: | + DMG_PATH="dist/Rayforge_${{ env.VERSION }}.dmg" + if [ ! -f "$DMG_PATH" ]; then + echo "DMG not found at $DMG_PATH" + exit 1 + fi + OUT_DMG="rayforge-${{ steps.set-version.outputs.version }}-macos-intel.dmg" + mv "$DMG_PATH" "$OUT_DMG" + + - name: Upload macOS bundle artifact + uses: actions/upload-artifact@v4 + with: + name: rayforge-${{ steps.set-version.outputs.version }}-macos-intel-app.zip + path: rayforge-${{ steps.set-version.outputs.version }}-macos-intel-app.zip + compression-level: 9 + + - name: Upload DMG artifact + uses: actions/upload-artifact@v4 + with: + name: rayforge-${{ steps.set-version.outputs.version }}-macos-intel-dmg + path: rayforge-${{ steps.set-version.outputs.version }}-macos-intel.dmg + + release: + name: Create GitHub Release (Intel) + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') && github.repository == 'barebaric/rayforge' + permissions: + contents: write + steps: + - name: Download macOS app artifact + uses: actions/download-artifact@v4 + with: + name: rayforge-${{ needs.build.outputs.version }}-macos-intel-app.zip + + - name: Download DMG artifact + uses: actions/download-artifact@v4 + with: + name: rayforge-${{ needs.build.outputs.version }}-macos-intel-dmg + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + rayforge-${{ needs.build.outputs.version }}-macos-intel-app.zip + rayforge-${{ needs.build.outputs.version }}-macos-intel.dmg + draft: false + prerelease: false + name: Release ${{ needs.build.outputs.version }} + tag_name: ${{ github.ref_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 4fa4fa77..6c5eba19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ venv .venv +.venv-mac *.swp *.py[oc] __pycache__ diff --git a/.mac_env b/.mac_env new file mode 100644 index 00000000..49535de5 --- /dev/null +++ b/.mac_env @@ -0,0 +1,10 @@ +export BREW_PREFIX="/usr/local" +export LIBFFI_PREFIX="/usr/local/opt/libffi" +export VENV_PATH="$PWD/.venv-mac" +export PATH="$VENV_PATH/bin:/usr/local/bin:/Library/Frameworks/Python.framework/Versions/3.7/bin:/opt/local/bin:/opt/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/Cellar/ffmpeg/4.3.1_2/bin:/usr/local/bin/python3:/usr/local/Cellar/python@3.11/3.11.2_1/bin/python3:/opt/X11/bin:/Library/Apple/usr/bin:/Library/Frameworks/Mono.framework/Versions/Current/Commands:/var/folders/qy/j2t2sz113nl3p_l7kc22ybb80000gn/T/.tmpr0xjAy:/usr/local/opt/node@20/bin:/opt/local/ffmpeg42/lib/pkgconfig:/Applications/Inkscape.app/Contents/MacOS:/opt/local/bin:/opt/local/sbin:/usr/local/opt/coreutils/libexec/gnubin:/usr/local/Cellar/python@3.11/3.11.2_1/Frameworks/Python.framework/Versions/3.11/Python:/usr/local/bin/python:/usr/local/opt/openexr@2/bin:/usr/local/sbin:/Users/pablo/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/Library/Frameworks/Python.framework/Versions/3.7/bin:/Users/pablo/.vscode/extensions/openai.chatgpt-0.4.46-darwin-x64/bin/macos-x86_64" +export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/local/opt/libffi/lib/pkgconfig:/opt/local/lib/pkgconfig:/opt/local/ffmpeg42/lib/pkgconfig:/Applications/Inkscape.app/Contents/MacOS:/usr/local/bin:/opt/local/bin:/opt/local/sbin:/usr/local/opt/coreutils/libexec/gnubin:/usr/local/Cellar/python@3.11/3.11.2_1/Frameworks/Python.framework/Versions/3.11/Python:/usr/local/bin/python3:/usr/local/bin/python:/usr/local/opt/openexr@2/bin:/usr/local/sbin:/usr/local/bin:/Users/pablo/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/Library/Frameworks/Python.framework/Versions/3.7/bin:/opt/local/bin:/opt/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/Cellar/ffmpeg/4.3.1_2/bin:/usr/local/bin/python3:/usr/local/Cellar/python@3.11/3.11.2_1/bin/python3:/opt/X11/bin:/Library/Apple/usr/bin:/Library/Frameworks/Mono.framework/Versions/Current/Commands" +export GI_TYPELIB_PATH="/usr/local/lib/girepository-1.0:" +export DYLD_FALLBACK_LIBRARY_PATH="/usr/local/lib:" +if ! echo "/opt/local/lib/pkgconfig:/opt/local/ffmpeg42/lib/pkgconfig:/Applications/Inkscape.app/Contents/MacOS:/usr/local/bin:/opt/local/bin:/opt/local/sbin:/usr/local/opt/coreutils/libexec/gnubin:/usr/local/Cellar/python@3.11/3.11.2_1/Frameworks/Python.framework/Versions/3.11/Python:/usr/local/bin/python3:/usr/local/bin/python:/usr/local/opt/openexr@2/bin:/usr/local/sbin:/usr/local/bin:/Users/pablo/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/Library/Frameworks/Python.framework/Versions/3.7/bin:/opt/local/bin:/opt/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/Cellar/ffmpeg/4.3.1_2/bin:/usr/local/bin/python3:/usr/local/Cellar/python@3.11/3.11.2_1/bin/python3:/opt/X11/bin:/Library/Apple/usr/bin:/Library/Frameworks/Mono.framework/Versions/Current/Commands" | tr ':' '\n' | grep -qx "/usr/local/lib/pkgconfig"; then + export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/opt/local/lib/pkgconfig:/opt/local/ffmpeg42/lib/pkgconfig:/Applications/Inkscape.app/Contents/MacOS:/usr/local/bin:/opt/local/bin:/opt/local/sbin:/usr/local/opt/coreutils/libexec/gnubin:/usr/local/Cellar/python@3.11/3.11.2_1/Frameworks/Python.framework/Versions/3.11/Python:/usr/local/bin/python3:/usr/local/bin/python:/usr/local/opt/openexr@2/bin:/usr/local/sbin:/usr/local/bin:/Users/pablo/.nix-profile/bin:/nix/var/nix/profiles/default/bin:/Library/Frameworks/Python.framework/Versions/3.7/bin:/opt/local/bin:/opt/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/Cellar/ffmpeg/4.3.1_2/bin:/usr/local/bin/python3:/usr/local/Cellar/python@3.11/3.11.2_1/bin/python3:/opt/X11/bin:/Library/Apple/usr/bin:/Library/Frameworks/Mono.framework/Versions/Current/Commands" +fi diff --git a/README.md b/README.md index f00ac21f..8977bac9 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,17 @@ For detailed information about developing for Rayforge, including setup instruct testing, and contribution guidelines, please see the [Developer Documentation](https://rayforge.org/docs/latest/developer/getting-started/). +## macOS build notes + +macOS builds rely on Homebrew because libadwaita is not available from the +conda-forge stack used by the Linux tooling. To prepare a build: + +1. Run `scripts/mac_setup.sh --install` to install the native GTK stack + (gtk4, libadwaita, libvips, etc.) and write `.mac_env`. +2. Source the environment with `source .mac_env`. +3. Build the wheel with `scripts/mac_build.sh`. Pass `--bundle` to also produce + a PyInstaller `.app` directory that uses the Homebrew libraries on the host. + ## License This project is licensed under the **MIT License**. See the `LICENSE` file for details. diff --git a/Rayforge.spec b/Rayforge.spec new file mode 100644 index 00000000..3a2998a6 --- /dev/null +++ b/Rayforge.spec @@ -0,0 +1,69 @@ +# -*- mode: python ; coding: utf-8 -*- + + +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.append('rayforge.ui_gtk.canvas2d.elements.workpiece') + +a = Analysis( + ['rayforge/app.py'], + pathex=['.'], + binaries=[], + datas=[ + ('rayforge/version.txt', 'rayforge'), + ('rayforge/resources', 'rayforge/resources'), + ('rayforge/locale', 'rayforge/locale'), + ], + 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=['rayforge/resources/icons/icon.icns'], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='Rayforge', +) +app = BUNDLE( + coll, + name='Rayforge.app', + icon='rayforge/resources/icons/icon.icns', + bundle_identifier='org.rayforge.rayforge', +) diff --git a/hooks/hook-gi.repository.Gtk.py b/hooks/hook-gi.repository.Gtk.py new file mode 100644 index 00000000..aa2965e7 --- /dev/null +++ b/hooks/hook-gi.repository.Gtk.py @@ -0,0 +1,53 @@ +#----------------------------------------------------------------------------- +# 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/pixi.toml b/pixi.toml index ebc0e67f..34fd32d6 100644 --- a/pixi.toml +++ b/pixi.toml @@ -41,6 +41,7 @@ build = "*" ezdxf = "==1.3.5" GitPython = "==3.1.44" platformdirs = "==4.3.6" +cairosvg = "==2.8.2" pluggy = "==1.6.0" pyclipper = "==1.3.0.post6" pypdf = "==6.6.2" diff --git a/pyinstaller_dyld_env.py b/pyinstaller_dyld_env.py new file mode 100644 index 00000000..c5411648 --- /dev/null +++ b/pyinstaller_dyld_env.py @@ -0,0 +1,20 @@ +import os +import sys +from pathlib import Path + +if hasattr(sys, "_MEIPASS"): + frameworks_dir = Path(sys._MEIPASS).parent / "Frameworks" + lib_path = str(frameworks_dir) + 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}" + ) + os.environ.setdefault("DYLD_FALLBACK_LIBRARY_PATH", lib_path) + + typelib_dir = frameworks_dir / "gi_typelibs" + if typelib_dir.exists(): + os.environ["GI_TYPELIB_PATH"] = str(typelib_dir) + + gio_modules = frameworks_dir / "gio_modules" + if gio_modules.exists(): + os.environ.setdefault("GIO_EXTRA_MODULES", str(gio_modules)) diff --git a/pyproject.toml b/pyproject.toml index aafa1e23..0d115d9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", + "Operating System :: MacOS :: MacOS X", "Intended Audience :: End Users/Desktop", "Intended Audience :: Manufacturing", ] @@ -70,6 +71,9 @@ test = [ "pytest-cov", # Add coverage tool "pygobject-stubs", ] +build = [ + "pyinstaller>=6.11,<7", +] [tool.pytest.ini_options] asyncio_mode = "strict" diff --git a/rayforge/app.py b/rayforge/app.py index 16bf104e..9db94849 100644 --- a/rayforge/app.py +++ b/rayforge/app.py @@ -47,14 +47,32 @@ # -------------------------------------------------------- # 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. +# When running in a PyInstaller bundle, point GI to the typelibs we ship. if hasattr(sys, "_MEIPASS"): - 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}") + 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) + 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}" + ) + os.environ.setdefault("DYLD_FALLBACK_LIBRARY_PATH", lib_path) + 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.") + if bundled_gio_modules.exists(): + os.environ.setdefault("GIO_EXTRA_MODULES", str(bundled_gio_modules)) def handle_exception(exc_type, exc_value, exc_traceback): @@ -95,7 +113,11 @@ def main(): class App(Adw.Application): def __init__(self, args): super().__init__(application_id="org.rayforge.rayforge") - self.set_accels_for_action("win.quit", ["Q"]) + from rayforge.ui_gtk.shared.keyboard import primary_accel + + self.set_accels_for_action( + "win.quit", [f"{primary_accel()}q"] + ) self.args = args self.win = None diff --git a/rayforge/core/geo/transform.py b/rayforge/core/geo/transform.py index 316af06a..51aee9b9 100644 --- a/rayforge/core/geo/transform.py +++ b/rayforge/core/geo/transform.py @@ -75,8 +75,6 @@ def grow_geometry(geometry: T_Geometry, offset: float) -> T_Geometry: # Pyclipper works with integers, so we need to scale our coordinates. CLIPPER_SCALE = 1e7 - pco = pyclipper.PyclipperOffset() # type: ignore - paths_to_offset = [] for i, data in enumerate(contour_data): logger.debug(f"Processing contour #{i} for pyclipper") @@ -105,16 +103,22 @@ def grow_geometry(geometry: T_Geometry, offset: float) -> T_Geometry: ] paths_to_offset.append(scaled_vertices) - pco.AddPaths( - paths_to_offset, - pyclipper.JT_MITER, # type: ignore - pyclipper.ET_CLOSEDPOLYGON, # type: ignore - ) - solution = pco.Execute(offset * CLIPPER_SCALE) + # Offset each contour independently to avoid unintended unions when + # adjacent shapes touch or overlap. This preserves shared edges as + # distinct toolpaths. + all_solutions = [] + for path in paths_to_offset: + pco = pyclipper.PyclipperOffset() # type: ignore + pco.AddPath( + path, + pyclipper.JT_MITER, # type: ignore + pyclipper.ET_CLOSEDPOLYGON, # type: ignore + ) + all_solutions.extend(pco.Execute(offset * CLIPPER_SCALE)) - logger.debug(f"Pyclipper generated {len(solution)} offset contours.") + logger.debug(f"Pyclipper generated {len(all_solutions)} offset contours.") - for new_contour_scaled in solution: + for new_contour_scaled in all_solutions: if len(new_contour_scaled) < 3: continue diff --git a/rayforge/core/workpiece.py b/rayforge/core/workpiece.py index 237a1968..f372c302 100644 --- a/rayforge/core/workpiece.py +++ b/rayforge/core/workpiece.py @@ -683,7 +683,20 @@ def _process_rendered_image_from_spec( return None # 2. Apply Mask - if spec.apply_mask: + # We skip masking for vector sources because they already render with + # correct transparency, and masking with vector geometry (which can + # be open lines with zero area) would incorrectly hide the content. + is_vector = False + if self.source and self.source.metadata.get("is_vector"): + is_vector = True + + # Special check for Sketch rendering: if we rendered via sketch def, + # it is vector data. + if self.sketch_uid: + is_vector = True + + if spec.apply_mask and not is_vector: + pre_mask_image = processed_image mask_geo = self._boundaries_y_down if mask_geo and not mask_geo.is_empty(): processed_image = image_util.apply_mask_to_vips_image( @@ -692,6 +705,19 @@ def _process_rendered_image_from_spec( if not processed_image: return None + # If masking wiped everything out (e.g., coordinate mismatch), + # fall back to the unmasked image for preview purposes. + try: + alpha = processed_image[3] + if alpha.max() <= 0: + logger.debug( + "Mask produced empty alpha; using unmasked image " + "for preview." + ) + processed_image = pre_mask_image + except Exception: + pass + # 3. Final Resize Check if ( processed_image.width != target_w diff --git a/rayforge/image/sketch/renderer.py b/rayforge/image/sketch/renderer.py index 7ecc340c..f5cd8687 100644 --- a/rayforge/image/sketch/renderer.py +++ b/rayforge/image/sketch/renderer.py @@ -222,9 +222,38 @@ def render_base_image( svg_string = "".join(svg_parts) try: - # Use svgload_buffer which is highly optimized - image = pyvips.Image.svgload_buffer(svg_string.encode("utf-8")) - return image + try: + import cairosvg + + png_bytes = cairosvg.svg2png( + bytestring=svg_string.encode("utf-8"), + output_width=width, + output_height=height, + ) + return pyvips.Image.pngload_buffer( + png_bytes, access=pyvips.Access.RANDOM + ) + except ImportError: + logger.error("CairoSVG is not available for sketch rendering.") + except (pyvips.Error, ValueError, TypeError, Exception) as e: + logger.error( + "CairoSVG fallback failed to render sketch SVG: %s", + e, + exc_info=True, + ) + + try: + svg_loader = getattr(pyvips.Image, "svgload_buffer") + except AttributeError: + svg_loader = None + + if svg_loader: + return svg_loader(svg_string.encode("utf-8")) + + logger.error( + "No SVG renderer succeeded for sketch " + "(CairoSVG/libvips unavailable)." + ) 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}") diff --git a/rayforge/image/svg/importer.py b/rayforge/image/svg/importer.py index 70a5b068..f812c670 100644 --- a/rayforge/image/svg/importer.py +++ b/rayforge/image/svg/importer.py @@ -1,36 +1,22 @@ +import logging from typing import Optional -from ...core.source_asset import SourceAsset + from ...core.vectorization_spec import ( - VectorizationSpec, - TraceSpec, PassthroughSpec, + TraceSpec, + VectorizationSpec, ) -from ..base_importer import ( - Importer, - ImporterFeature, -) -from ..structures import ( - ParsingResult, - VectorizationResult, - ImportResult, - ImportManifest, -) +from ..base_importer import Importer, ImporterFeature +from ..structures import ImportResult, ParsingResult, VectorizationResult from .svg_trace import SvgTraceImporter from .svg_vector import SvgVectorImporter - -import logging - 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. + Facade importer for SVG files. """ label = "SVG files" @@ -42,10 +28,26 @@ class SvgImporter(Importer): ImporterFeature.LAYER_SELECTION, } - def scan(self) -> ImportManifest: - # Use Vector importer for scanning as it's lightweight/standard + def scan(self): return SvgVectorImporter(self.raw_data, self.source_file).scan() + def parse(self) -> Optional[ParsingResult]: + 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): + raise NotImplementedError( + "SvgImporter is a facade; create_source_asset is delegated" + ) + def get_doc_items( self, vectorization_spec: Optional[VectorizationSpec] = None ) -> Optional[ImportResult]: @@ -53,7 +55,6 @@ def get_doc_items( 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() @@ -61,9 +62,6 @@ def get_doc_items( 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 @@ -76,10 +74,9 @@ def get_doc_items( 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}" + "Populating spec with all layers: %s", + all_layer_ids, ) - # Create a new spec object that matches the UI's default. - # This ensures the "merge" strategy is used in the engine. spec_to_use = PassthroughSpec( active_layer_ids=all_layer_ids, create_new_layers=False, @@ -88,83 +85,4 @@ def get_doc_items( logger.debug("SvgImporter: Delegating to SvgVectorImporter.") delegate = SvgVectorImporter(self.raw_data, self.source_file) - import_result = delegate.get_doc_items(spec_to_use) - - # --- DIAGNOSTIC LOGGING --- - if ( - import_result - and import_result.payload - and import_result.payload.items - ): - from ...core.workpiece import WorkPiece - from ...core.layer import Layer - - 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): - if 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) -> Optional[ParsingResult]: - 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" - ) + return delegate.get_doc_items(spec_to_use) diff --git a/rayforge/image/svg/renderer.py b/rayforge/image/svg/renderer.py index bc84e7c4..3ca32f87 100644 --- a/rayforge/image/svg/renderer.py +++ b/rayforge/image/svg/renderer.py @@ -1,3 +1,4 @@ +import logging import warnings import logging from typing import Optional, TYPE_CHECKING, List, Tuple @@ -7,6 +8,8 @@ from ...core.vectorization_spec import TraceSpec +logger = logging.getLogger(__name__) + with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) import pyvips @@ -132,14 +135,43 @@ def render_base_image( root.set("style", "overflow: visible") svg_bytes = ET.tostring(root) - image = pyvips.Image.svgload_buffer(svg_bytes) - # logger.debug( - # f"SvgRenderer.render_base_image: requested width={width}, " - # f"height={height}, actual image width={image.width}, " - # f"height={image.height}" - # ) - return image - except (pyvips.Error, ET.ParseError, ValueError, TypeError): + # Prefer CairoSVG because bundled libvips may lack SVG support on + # macOS. Fall back to svgload_buffer if available. + try: + import cairosvg + + png_bytes = cairosvg.svg2png( + bytestring=svg_bytes, + output_width=width, + output_height=height, + ) + logger.debug("Rendered SVG via CairoSVG fallback path.") + return pyvips.Image.pngload_buffer( + png_bytes, access=pyvips.Access.RANDOM + ) + except ImportError: + logger.error("CairoSVG is not available for SVG rendering.") + except (pyvips.Error, ValueError, TypeError, Exception) as e: + logger.error( + "CairoSVG fallback failed to render SVG: %s", + e, + exc_info=True, + ) + + try: + svg_loader = getattr(pyvips.Image, "svgload_buffer") + except AttributeError: + svg_loader = None + + if svg_loader: + logger.debug("Rendered SVG via libvips svgload_buffer.") + return svg_loader(svg_bytes) + + logger.error( + "No SVG renderer succeeded (CairoSVG/libvips unavailable)." + ) + except (pyvips.Error, ET.ParseError, ValueError, TypeError) as e: + logger.error(f"Failed to render SVG: {e}", exc_info=True) return None diff --git a/rayforge/image/svg/svgutil.py b/rayforge/image/svg/svgutil.py index 03df41f3..531902e9 100644 --- a/rayforge/image/svg/svgutil.py +++ b/rayforge/image/svg/svgutil.py @@ -1,7 +1,6 @@ import warnings from typing import Tuple, Optional, List, Dict, Any from xml.etree import ElementTree as ET -import logging with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) @@ -9,8 +8,6 @@ from ..util import parse_length, to_mm -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.""" @@ -19,32 +16,78 @@ """Conversion factor for pixels to millimeters, based on 96 PPI.""" 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 Exception: - pass # Best effort registration + + +def extract_layer_manifest(data: bytes) -> List[Dict[str, Any]]: + """ + Extracts layer metadata from an SVG. + + Returns a list of dicts containing: + - id: The layer group id + - name: The layer label + - count: Number of direct child elements in the layer + """ + if not data: + return [] + + try: + root = ET.fromstring(data) + except ET.ParseError: + return [] + + layers: List[Dict[str, Any]] = [] + layer_attr = f"{{{INKSCAPE_NS}}}groupmode" + label_attr = f"{{{INKSCAPE_NS}}}label" + + for elem in root.iter(): + if elem.get(layer_attr) != "layer": + continue + layer_id = elem.get("id") + if not layer_id: + continue + label = elem.get(label_attr) or layer_id + layers.append( + { + "id": layer_id, + "name": label, + "count": len(list(elem)), + } + ) + + return layers + + +def filter_svg_layers(data: bytes, visible_layer_ids: List[str]) -> bytes: + """ + Filters an SVG to only include the specified layer ids. + """ + if not data or not visible_layer_ids: + return data + + try: + root = ET.fromstring(data) + except ET.ParseError: + return data + + layer_attr = f"{{{INKSCAPE_NS}}}groupmode" + layer_groups = [ + elem for elem in root.iter() if elem.get(layer_attr) == "layer" + ] + if not layer_groups: + return data + + visible = set(visible_layer_ids) + for group in layer_groups: + layer_id = group.get("id") + if layer_id and layer_id not in visible: + parent = root + for parent in root.iter(): + if group in list(parent): + parent.remove(group) + break + + filtered = ET.tostring(root) + return filtered def _get_margins_from_data( @@ -84,21 +127,22 @@ def _get_margins_from_data( render_w = measurement_size * aspect_ratio # 3. Modify SVG for a large, proportional render. + # DO NOT set preserveAspectRatio="none", as this causes distortion. 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") + try: + svg_loader = getattr(pyvips.Image, "svgload_buffer") + except Exception: + # Libvips compiled without SVG loader; skip trimming. + return 0.0, 0.0, 0.0, 0.0 - img = pyvips.Image.svgload_buffer(ET.tostring(root)) + img = svg_loader(ET.tostring(root)) if img.bands < 4: img = img.bandjoin(255) # Ensure alpha channel for trimming @@ -119,7 +163,13 @@ def _get_margins_from_data( (render_w - (left + w)) / render_w, (render_h - (top + h)) / render_h, ) - except (pyvips.Error, ET.ParseError, ValueError): + except ( + pyvips.Error, + ET.ParseError, + ValueError, + AttributeError, + ModuleNotFoundError, + ): # Return zero margins if SVG is invalid or processing fails return 0.0, 0.0, 0.0, 0.0 @@ -183,11 +233,8 @@ def trim_svg(data: bytes) -> bytes: 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"] + # The content should fill the new view, so set aspect ratio to none + root.set("preserveAspectRatio", "none") return ET.tostring(root) @@ -228,80 +275,3 @@ def get_natural_size(data: bytes) -> Optional[Tuple[float, float]]: 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 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/pipeline/artifact/manager.py b/rayforge/pipeline/artifact/manager.py index 0c776bf7..fc66266f 100644 --- a/rayforge/pipeline/artifact/manager.py +++ b/rayforge/pipeline/artifact/manager.py @@ -110,6 +110,7 @@ def adopt_artifact( self, key: Union[WorkPieceKey, StepKey, JobKey], handle_dict: Dict[str, Any], + in_process: bool = False, ) -> BaseArtifactHandle: """ Adopts an artifact from a subprocess and deserializes the handle. @@ -127,7 +128,7 @@ def adopt_artifact( The adopted, deserialized handle. """ handle = create_handle_from_dict(handle_dict) - self._store.adopt(handle) + self._store.adopt(handle, increment_refcount=not in_process) return handle def retain_handle(self, handle: BaseArtifactHandle): diff --git a/rayforge/pipeline/artifact/store.py b/rayforge/pipeline/artifact/store.py index 888a30b9..472df760 100644 --- a/rayforge/pipeline/artifact/store.py +++ b/rayforge/pipeline/artifact/store.py @@ -33,9 +33,11 @@ def __init__(self): def shutdown(self): for shm_name in list(self._managed_shms.keys()): - self._release_by_name(shm_name) + self._force_release_by_name(shm_name) - def adopt(self, handle: BaseArtifactHandle) -> None: + def adopt( + self, handle: BaseArtifactHandle, increment_refcount: bool = True + ) -> None: """ Takes ownership of a shared memory block created by another process. @@ -51,12 +53,15 @@ def adopt(self, handle: BaseArtifactHandle) -> None: """ shm_name = handle.shm_name if shm_name in self._managed_shms: - # Increment refcount for already-managed block - self._refcounts[shm_name] = self._refcounts.get(shm_name, 1) + 1 - logger.debug( - f"Shared memory block {shm_name} refcount incremented to " - f"{self._refcounts[shm_name]}" - ) + if increment_refcount: + # Increment refcount for already-managed block + self._refcounts[shm_name] = ( + self._refcounts.get(shm_name, 1) + 1 + ) + logger.debug( + f"Shared memory block {shm_name} refcount incremented to " + f"{self._refcounts[shm_name]}" + ) return try: @@ -83,7 +88,9 @@ def put( total_bytes = sum(arr.nbytes for arr in arrays.values()) # Create the shared memory block - shm_name = f"rayforge_artifact_{creator_tag}_{uuid.uuid4()}" + # macOS has a 31-character limit for shared memory names + short_uuid = str(uuid.uuid4())[:8] + shm_name = f"rf_{short_uuid}" try: # Prevent creating a zero-size block, which raises a ValueError. # A 1-byte block is a safe, minimal placeholder. @@ -215,6 +222,23 @@ def _release_by_name(self, shm_name: str) -> None: f"Error releasing shared memory block {shm_name}: {e}" ) + def _force_release_by_name(self, shm_name: str) -> None: + shm_obj = self._managed_shms.pop(shm_name, None) + if not shm_obj: + return + self._refcounts.pop(shm_name, None) + + try: + shm_obj.close() + shm_obj.unlink() + logger.debug(f"Released shared memory block: {shm_name}") + except FileNotFoundError: + logger.debug(f"SHM block {shm_name} was already unlinked.") + except Exception as e: + logger.warning( + f"Error releasing shared memory block {shm_name}: {e}" + ) + def release(self, handle: BaseArtifactHandle) -> None: """ Closes and unlinks the shared memory block associated with a handle. diff --git a/rayforge/pipeline/encoder/vertexencoder.py b/rayforge/pipeline/encoder/vertexencoder.py index e8451a5f..2f7f8d34 100644 --- a/rayforge/pipeline/encoder/vertexencoder.py +++ b/rayforge/pipeline/encoder/vertexencoder.py @@ -52,11 +52,18 @@ def encode(self, ops: Ops) -> VertexData: travel_v: List[float] = [] zero_power_v: List[float] = [] + # Ensure each command carries the intended machine state, so power + # values are available even if SetPower commands were stripped. + ops.preload_state() + # Track current state current_power = 0.0 current_pos = (0.0, 0.0, 0.0) for cmd in ops.commands: + if hasattr(cmd, "state") and cmd.state is not None: + current_power = getattr(cmd.state, "power", current_power) + if isinstance(cmd, SetPowerCommand): current_power = cmd.power continue diff --git a/rayforge/pipeline/stage/job_stage.py b/rayforge/pipeline/stage/job_stage.py index 7fbadfcf..5b79f4b1 100644 --- a/rayforge/pipeline/stage/job_stage.py +++ b/rayforge/pipeline/stage/job_stage.py @@ -1,5 +1,7 @@ from __future__ import annotations import logging +import sys +import threading from typing import TYPE_CHECKING, Optional, Callable from blinker import Signal import multiprocessing as mp @@ -10,7 +12,6 @@ from contextlib import ExitStack if TYPE_CHECKING: - import threading from ...core.doc import Doc from ...machine.models.machine import Machine from ...shared.tasker.manager import TaskManager @@ -37,6 +38,7 @@ def __init__( self._machine = machine self._active_task: Optional["Task"] = None self._adoption_event: Optional["threading.Event"] = None + self._use_thread = False self.generation_finished = Signal() self.generation_failed = Signal() @@ -113,9 +115,13 @@ def generate_job(self, doc: "Doc", on_done: Optional[Callable] = None): self._artifact_manager.invalidate_for_job() - # Create an adoption event for the handshake protocol - manager = mp.Manager() - self._adoption_event = manager.Event() + use_thread = sys.platform == "darwin" and hasattr(sys, "_MEIPASS") + self._use_thread = use_thread + if use_thread: + self._adoption_event = None + else: + manager = mp.Manager() + self._adoption_event = manager.Event() job_desc = JobDescription( step_artifact_handles_by_uid=step_handles, @@ -176,16 +182,28 @@ def when_done_callback(task: "Task"): ) # We no longer need _on_job_assembly_complete - task = self._task_manager.run_process( - make_job_artifact_in_subprocess, - self._artifact_manager._store, - job_description_dict=job_desc.__dict__, - creator_tag="job", - key=JobKey, - when_done=when_done_callback, - when_event=self._on_job_task_event, - adoption_event=self._adoption_event, - ) + if use_thread: + task = self._task_manager.run_thread_with_proxy( + make_job_artifact_in_subprocess, + self._artifact_manager._store, + job_description_dict=job_desc.__dict__, + creator_tag="job", + key=JobKey, + when_done=when_done_callback, + when_event=self._on_job_task_event, + adoption_event=self._adoption_event, + ) + else: + task = self._task_manager.run_process( + make_job_artifact_in_subprocess, + self._artifact_manager._store, + job_description_dict=job_desc.__dict__, + creator_tag="job", + key=JobKey, + when_done=when_done_callback, + when_event=self._on_job_task_event, + adoption_event=self._adoption_event, + ) self._active_task = task def _on_job_task_event(self, task: "Task", event_name: str, data: dict): @@ -206,7 +224,9 @@ def _on_job_task_event(self, task: "Task", event_name: str, data: dict): try: handle_dict = data["handle_dict"] handle = self._artifact_manager.adopt_artifact( - JobKey, handle_dict + JobKey, + handle_dict, + in_process=self._use_thread, ) if not isinstance(handle, JobArtifactHandle): raise TypeError("Expected a JobArtifactHandle") diff --git a/rayforge/pipeline/stage/step_stage.py b/rayforge/pipeline/stage/step_stage.py index 2159584b..77f68286 100644 --- a/rayforge/pipeline/stage/step_stage.py +++ b/rayforge/pipeline/stage/step_stage.py @@ -1,6 +1,8 @@ from __future__ import annotations import logging import math +import sys +import threading from typing import TYPE_CHECKING, Dict, Optional import multiprocessing as mp from contextlib import ExitStack @@ -12,7 +14,6 @@ from .base import PipelineStage if TYPE_CHECKING: - import threading from ...core.doc import Doc from ...core.step import Step from ...machine.models.machine import Machine @@ -43,6 +44,7 @@ def __init__( self._generation_id_map: Dict[StepKey, int] = {} self._active_tasks: Dict[StepKey, "Task"] = {} self._adoption_events: Dict[StepKey, "threading.Event"] = {} + self._thread_tasks: Dict[StepKey, bool] = {} # Local cache for accurate, post-transformer time estimates self._time_cache: Dict[StepKey, Optional[float]] = {} @@ -115,6 +117,7 @@ def _cleanup_task(self, key: StepKey): logger.debug(f"Cancelling active step task for {key}") self._task_manager.cancel_task(task.key) self._adoption_events.pop(key, None) + self._thread_tasks.pop(key, None) def _cleanup_entry(self, key: StepKey, full_invalidation: bool): """Removes a step artifact, clears time cache, and cancels its task.""" @@ -205,29 +208,50 @@ def when_done_callback(task: "Task"): def when_event_callback(task: "Task", event_name: str, data: dict): self._on_task_event(task, event_name, data, step) - # Create an adoption event for the handshake protocol - # Use Manager to create a picklable Event that can be passed - # through queues - manager = mp.Manager() - adoption_event = manager.Event() - self._adoption_events[step.uid] = adoption_event - - task = self._task_manager.run_process( - make_step_artifact_in_subprocess, - self._artifact_manager._store, - assembly_info, - step.uid, - generation_id, - step.per_step_transformers_dicts, - machine.max_cut_speed, - machine.max_travel_speed, - machine.acceleration, - "step", - adoption_event=adoption_event, - key=step.uid, - when_done=when_done_callback, - when_event=when_event_callback, # Connect event listener - ) + use_thread = sys.platform == "darwin" and hasattr(sys, "_MEIPASS") + if use_thread: + adoption_event = None + else: + manager = mp.Manager() + adoption_event = manager.Event() + if adoption_event is not None: + self._adoption_events[step.uid] = adoption_event + self._thread_tasks[step.uid] = use_thread + + if use_thread: + task = self._task_manager.run_thread_with_proxy( + make_step_artifact_in_subprocess, + self._artifact_manager._store, + assembly_info, + step.uid, + generation_id, + step.per_step_transformers_dicts, + machine.max_cut_speed, + machine.max_travel_speed, + machine.acceleration, + "step", + adoption_event=adoption_event, + key=step.uid, + when_done=when_done_callback, + when_event=when_event_callback, + ) + else: + task = self._task_manager.run_process( + make_step_artifact_in_subprocess, + self._artifact_manager._store, + assembly_info, + step.uid, + generation_id, + step.per_step_transformers_dicts, + machine.max_cut_speed, + machine.max_travel_speed, + machine.acceleration, + "step", + adoption_event=adoption_event, + key=step.uid, + when_done=when_done_callback, + when_event=when_event_callback, # Connect event listener + ) self._active_tasks[step.uid] = task def _on_task_event( @@ -245,7 +269,9 @@ def _on_task_event( if event_name == "render_artifact_ready": handle_dict = data["handle_dict"] handle = self._artifact_manager.adopt_artifact( - step_uid, handle_dict + step_uid, + handle_dict, + in_process=self._thread_tasks.get(step_uid, False), ) if not isinstance(handle, StepRenderArtifactHandle): raise TypeError("Expected a StepRenderArtifactHandle") @@ -256,7 +282,9 @@ def _on_task_event( elif event_name == "ops_artifact_ready": handle_dict = data["handle_dict"] handle = self._artifact_manager.adopt_artifact( - step_uid, handle_dict + step_uid, + handle_dict, + in_process=self._thread_tasks.get(step_uid, False), ) if not isinstance(handle, StepOpsArtifactHandle): raise TypeError("Expected a StepOpsArtifactHandle") @@ -288,6 +316,7 @@ def _on_assembly_complete( step_uid = step.uid self._active_tasks.pop(step_uid, None) self._adoption_events.pop(step_uid, None) + self._thread_tasks.pop(step_uid, None) if self._generation_id_map.get(step_uid) != task_generation_id: return diff --git a/rayforge/pipeline/stage/workpiece_stage.py b/rayforge/pipeline/stage/workpiece_stage.py index e4da9f56..e29d4904 100644 --- a/rayforge/pipeline/stage/workpiece_stage.py +++ b/rayforge/pipeline/stage/workpiece_stage.py @@ -2,6 +2,8 @@ import logging import math import multiprocessing as mp +import sys +import threading from typing import TYPE_CHECKING, Dict, Tuple, Optional from blinker import Signal from copy import deepcopy @@ -46,6 +48,7 @@ def __init__( self._generation_id_map: Dict[WorkpieceKey, int] = {} self._active_tasks: Dict[WorkpieceKey, "Task"] = {} self._adoption_events: Dict[WorkpieceKey, "threading.Event"] = {} + self._thread_tasks: Dict[WorkpieceKey, bool] = {} # Signals for notifying the pipeline of generation progress self.generation_starting = Signal() @@ -168,6 +171,7 @@ def _cleanup_task(self, key: WorkpieceKey): logger.debug(f"Requesting cancellation for active task {key}") self._task_manager.cancel_task(task.key) self._adoption_events.pop(key, None) + self._thread_tasks.pop(key, None) def _cleanup_entry(self, key: WorkpieceKey): """ @@ -229,28 +233,52 @@ def when_done_callback(task: "Task"): world_workpiece = workpiece.in_world() workpiece_dict = world_workpiece.to_dict() - # Create an adoption event for the handshake protocol - manager = mp.Manager() - adoption_event = manager.Event() - self._adoption_events[key] = adoption_event - - task = self._task_manager.run_process( - make_workpiece_artifact_in_subprocess, - self._artifact_manager._store, - workpiece_dict, - step.opsproducer_dict, - step.modifiers_dicts, - step.per_workpiece_transformers_dicts, - selected_laser.to_dict(), - settings, - generation_id, - workpiece.size, - "workpiece", - adoption_event=adoption_event, - key=key, - when_done=when_done_callback, - when_event=self._on_task_event_received, - ) + use_thread = sys.platform == "darwin" and hasattr(sys, "_MEIPASS") + if use_thread: + adoption_event = None + else: + manager = mp.Manager() + adoption_event = manager.Event() + if adoption_event is not None: + self._adoption_events[key] = adoption_event + self._thread_tasks[key] = use_thread + + if use_thread: + task = self._task_manager.run_thread_with_proxy( + make_workpiece_artifact_in_subprocess, + self._artifact_manager._store, + workpiece_dict, + step.opsproducer_dict, + step.modifiers_dicts, + step.per_workpiece_transformers_dicts, + selected_laser.to_dict(), + settings, + generation_id, + workpiece.size, + "workpiece", + adoption_event=adoption_event, + key=key, + when_done=when_done_callback, + when_event=self._on_task_event_received, + ) + else: + task = self._task_manager.run_process( + make_workpiece_artifact_in_subprocess, + self._artifact_manager._store, + workpiece_dict, + step.opsproducer_dict, + step.modifiers_dicts, + step.per_workpiece_transformers_dicts, + selected_laser.to_dict(), + settings, + generation_id, + workpiece.size, + "workpiece", + adoption_event=adoption_event, + key=key, + when_done=when_done_callback, + when_event=self._on_task_event_received, + ) self._active_tasks[key] = task def _on_task_event_received( @@ -272,7 +300,9 @@ def _on_task_event_received( ) try: stale_handle = self._artifact_manager.adopt_artifact( - (s_uid, w_uid), handle_dict + (s_uid, w_uid), + handle_dict, + in_process=self._thread_tasks.get(key, False), ) self._artifact_manager.release_handle(stale_handle) except Exception as e: @@ -281,7 +311,9 @@ def _on_task_event_received( try: handle = self._artifact_manager.adopt_artifact( - (s_uid, w_uid), handle_dict + (s_uid, w_uid), + handle_dict, + in_process=self._thread_tasks.get(key, False), ) if event_name == "artifact_created": @@ -344,6 +376,7 @@ def _on_task_complete( if self._active_tasks.get(key) is task: self._active_tasks.pop(key, None) self._adoption_events.pop(key, None) + self._thread_tasks.pop(key, None) logger.debug( f"[{key}] Popped active task {id(task)} from tracking." ) diff --git a/rayforge/pipeline/stage/workpiece_view_stage.py b/rayforge/pipeline/stage/workpiece_view_stage.py index 7d4ccfbf..be677ffc 100644 --- a/rayforge/pipeline/stage/workpiece_view_stage.py +++ b/rayforge/pipeline/stage/workpiece_view_stage.py @@ -4,6 +4,7 @@ import numpy as np import threading import time +import sys from multiprocessing import shared_memory from typing import TYPE_CHECKING, Any, Dict, Tuple, cast from blinker import Signal @@ -58,6 +59,7 @@ def __init__( self._machine = machine self._active_tasks: Dict[ViewKey, "Task"] = {} self._last_context_cache: Dict[ViewKey, RenderContext] = {} + self._thread_tasks: Dict[ViewKey, bool] = {} # Track the currently active handle for each view so we can release # it when it is replaced or when the stage shuts down. @@ -103,6 +105,7 @@ def shutdown(self): task = self._active_tasks.pop(key, None) if task: self._task_manager.cancel_task(task.key) + self._thread_tasks.pop(key, None) # Release all currently held view handles for handle in self._current_view_handles.values(): @@ -199,16 +202,30 @@ def request_view_render( def when_done_callback(task: "Task"): self._on_render_complete(task, key) - task = self._task_manager.run_process( - make_workpiece_view_artifact_in_subprocess, - self._artifact_manager._store, - workpiece_artifact_handle_dict=source_handle.to_dict(), - render_context_dict=context.to_dict(), - creator_tag="workpiece_view", - key=key, - when_done=when_done_callback, - when_event=self._on_render_event_received, - ) + use_thread = sys.platform == "darwin" and hasattr(sys, "_MEIPASS") + self._thread_tasks[key] = use_thread + if use_thread: + task = self._task_manager.run_thread_with_proxy( + make_workpiece_view_artifact_in_subprocess, + self._artifact_manager._store, + workpiece_artifact_handle_dict=source_handle.to_dict(), + render_context_dict=context.to_dict(), + creator_tag="workpiece_view", + key=key, + when_done=when_done_callback, + when_event=self._on_render_event_received, + ) + else: + task = self._task_manager.run_process( + make_workpiece_view_artifact_in_subprocess, + self._artifact_manager._store, + workpiece_artifact_handle_dict=source_handle.to_dict(), + render_context_dict=context.to_dict(), + creator_tag="workpiece_view", + key=key, + when_done=when_done_callback, + when_event=self._on_render_event_received, + ) self._active_tasks[key] = task def _on_render_event_received( @@ -222,7 +239,9 @@ def _on_render_event_received( try: handle_dict = data["handle_dict"] handle = self._artifact_manager.adopt_artifact( - key, handle_dict + key, + handle_dict, + in_process=self._thread_tasks.get(key, False), ) if not isinstance(handle, WorkPieceViewArtifactHandle): raise TypeError("Expected WorkPieceViewArtifactHandle") @@ -577,6 +596,7 @@ def _on_render_complete(self, task: "Task", key: ViewKey): cleanup and state management. """ self._active_tasks.pop(key, None) + self._thread_tasks.pop(key, None) if task.get_status() != "completed": logger.error( diff --git a/rayforge/resources/icons/icon.icns b/rayforge/resources/icons/icon.icns new file mode 100644 index 00000000..c65a2588 Binary files /dev/null and b/rayforge/resources/icons/icon.icns differ diff --git a/rayforge/shared/tasker/manager.py b/rayforge/shared/tasker/manager.py index 383876ef..ea711aae 100644 --- a/rayforge/shared/tasker/manager.py +++ b/rayforge/shared/tasker/manager.py @@ -17,6 +17,7 @@ from blinker import Signal from ..util.glib import idle_add from .context import ExecutionContext +from .proxy import BaseExecutionContext from .task import Task from .pool import WorkerPoolManager @@ -24,6 +25,57 @@ logger = logging.getLogger(__name__) +class ThreadExecutionProxy(BaseExecutionContext): + def __init__( + self, + context: ExecutionContext, + event_callback: Optional[Callable[[str, dict], None]] = None, + base_progress: float = 0.0, + progress_range: float = 1.0, + total: float = 1.0, + ): + super().__init__( + base_progress=base_progress, + progress_range=progress_range, + total=total, + ) + self._context = context + self._event_callback = event_callback + + def _report_normalized_progress(self, progress: float): + progress = max(0.0, min(1.0, progress)) + scaled_progress = self._base + (progress * self._range) + self._context.set_progress(scaled_progress) + + def set_message(self, message: str): + self._context.set_message(message) + + def send_event(self, name: str, data: Optional[dict] = None): + if self._event_callback is not None: + self._event_callback(name, data if data is not None else {}) + + def _create_sub_context( + self, + base_progress: float, + progress_range: float, + total: float, + **kwargs, + ) -> "ThreadExecutionProxy": + return ThreadExecutionProxy( + self._context, + event_callback=self._event_callback, + base_progress=base_progress, + progress_range=progress_range, + total=total, + ) + + def is_cancelled(self) -> bool: + return self._context.is_cancelled() + + def flush(self): + self._context.flush() + + class TaskManager: def __init__( self, @@ -201,6 +253,37 @@ async def thread_wrapper( self.add_task(task) return task + def run_thread_with_proxy( + self, + func: Callable[..., Any], + *args: Any, + key: Optional[Any] = None, + when_done: Optional[Callable[[Task], None]] = None, + when_event: Optional[Callable[[Task, str, dict], None]] = None, + **kwargs: Any, + ) -> Task: + async def thread_wrapper( + context: ExecutionContext, *args: Any, **kwargs: Any + ) -> Any: + task = context.task + + def send_event(name: str, data: dict): + if when_event and task is not None: + self.schedule_on_main_thread( + when_event, task, name, data + ) + + proxy = ThreadExecutionProxy(context, event_callback=send_event) + bound_func = lambda: func(proxy, *args, **kwargs) + result = await self.run_in_executor(bound_func) + return result + + 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], diff --git a/rayforge/ui_gtk/actions.py b/rayforge/ui_gtk/actions.py index d97b09a3..0c08c67a 100644 --- a/rayforge/ui_gtk/actions.py +++ b/rayforge/ui_gtk/actions.py @@ -6,6 +6,7 @@ from ..core.layer import Layer from ..core.workpiece import WorkPiece from .doceditor.add_tabs_popover import AddTabsPopover +from .shared.keyboard import primary_accel if TYPE_CHECKING: @@ -348,34 +349,38 @@ def register_shortcuts(self, controller: Gtk.ShortcutController): """ Populates the given ShortcutController with all application shortcuts. """ + primary = primary_accel() + def with_primary(shortcut: str) -> str: + return shortcut.replace("", primary) + shortcuts = { # File - "win.new": "n", - "win.open": "o", - "win.save": "s", - "win.save-as": "s", - "win.import": "i", - "win.export": "e", - "win.quit": "q", + "win.new": with_primary("n"), + "win.open": with_primary("o"), + "win.save": with_primary("s"), + "win.save-as": with_primary("s"), + "win.import": with_primary("i"), + "win.export": with_primary("e"), + "win.quit": with_primary("q"), # Edit - "win.undo": "z", - "win.redo": "y", - "win.redo_alt": "z", - "win.cut": "x", - "win.copy": "c", - "win.paste": "v", - "win.select_all": "a", - "win.duplicate": "d", + "win.undo": with_primary("z"), + "win.redo": with_primary("y"), + "win.redo_alt": with_primary("z"), + "win.cut": with_primary("x"), + "win.copy": with_primary("c"), + "win.paste": with_primary("v"), + "win.select_all": with_primary("a"), + "win.duplicate": with_primary("d"), "win.remove": "Delete", - "win.clear": "Delete", - "win.settings": "comma", + "win.clear": with_primary("Delete"), + "win.settings": with_primary("comma"), # View "win.show_workpieces": "h", "win.show_tabs": "t", "win.toggle_camera_view": "c", - "win.toggle_control_panel": "l", - "win.toggle_gcode_preview": "g", - "win.toggle_travel_view": "t", + "win.toggle_control_panel": with_primary("l"), + "win.toggle_gcode_preview": with_primary("g"), + "win.toggle_travel_view": with_primary("t"), "win.show_3d_view": "F12", "win.simulate_mode": "F11", "win.view_top": "1", @@ -384,27 +389,27 @@ def register_shortcuts(self, controller: Gtk.ShortcutController): "win.view_toggle_perspective": "p", # Object "win.add_stock": "s", - "win.new_sketch": "n", + "win.new_sketch": with_primary("n"), "win.add-tabs-equidistant": "t", # Arrange - "win.group": "g", - "win.ungroup": "u", + "win.group": with_primary("g"), + "win.ungroup": with_primary("u"), "win.split": "w", - "win.layer-move-up": "Page_Up", - "win.layer-move-down": "Page_Down", - "win.align-left": "Left", - "win.align-right": "Right", - "win.align-top": "Up", - "win.align-bottom": "Down", - "win.align-h-center": "Home", - "win.align-v-center": "End", - "win.spread-h": "h", - "win.spread-v": "v", + "win.layer-move-up": with_primary("Page_Up"), + "win.layer-move-down": with_primary("Page_Down"), + "win.align-left": with_primary("Left"), + "win.align-right": with_primary("Right"), + "win.align-top": with_primary("Up"), + "win.align-bottom": with_primary("Down"), + "win.align-h-center": with_primary("Home"), + "win.align-v-center": with_primary("End"), + "win.spread-h": with_primary("h"), + "win.spread-v": with_primary("v"), "win.layout-pixel-perfect": "a", "win.flip-horizontal": "h", "win.flip-vertical": "v", # Machine & Help - "win.machine-settings": "less", + "win.machine-settings": with_primary("less"), "win.about": "F1", } diff --git a/rayforge/ui_gtk/canvas/canvas.py b/rayforge/ui_gtk/canvas/canvas.py index 271b2ec1..0ce42ce0 100644 --- a/rayforge/ui_gtk/canvas/canvas.py +++ b/rayforge/ui_gtk/canvas/canvas.py @@ -21,6 +21,7 @@ from .multiselect import MultiSelectionGroup from .overlays import render_selection_handles, render_selection_frame from .intersect import obb_intersects_aabb +from ..shared.keyboard import is_primary_keyval logger = logging.getLogger(__name__) @@ -1271,7 +1272,7 @@ def on_key_pressed( if keyval in (Gdk.KEY_Shift_L, Gdk.KEY_Shift_R): self._shift_pressed = True # Allow propagation for accelerators - elif keyval in (Gdk.KEY_Control_L, Gdk.KEY_Control_R): + elif is_primary_keyval(keyval): self._ctrl_pressed = True # Allow propagation for accelerators elif keyval == Gdk.KEY_Delete: @@ -1289,7 +1290,7 @@ def on_key_released( """Handles key release events for modifiers.""" if keyval in (Gdk.KEY_Shift_L, Gdk.KEY_Shift_R): self._shift_pressed = False - elif keyval in (Gdk.KEY_Control_L, Gdk.KEY_Control_R): + elif is_primary_keyval(keyval): self._ctrl_pressed = False def get_active_element(self) -> Optional[CanvasElement]: diff --git a/rayforge/ui_gtk/canvas/element.py b/rayforge/ui_gtk/canvas/element.py index c393265d..8353f30c 100644 --- a/rayforge/ui_gtk/canvas/element.py +++ b/rayforge/ui_gtk/canvas/element.py @@ -370,6 +370,7 @@ def _start_update(self) -> bool: self._update_future.cancel() if not self.canvas: + logger.debug(f"{self.__class__.__name__}: No canvas, skipping update") return False # Calculate the total transformation from this element's local space @@ -386,17 +387,24 @@ def _start_update(self) -> bool: render_width = round(self.width * scale_x) render_height = round(self.height * scale_y) + logger.debug( + f"{self.__class__.__name__}: Calculated render size: {render_width}x{render_height} " + f"(element size: {self.width}x{self.height}, scale: {scale_x:.2f}x{scale_y:.2f})" + ) + # 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. + logger.debug(f"{self.__class__.__name__}: Render size is zero or negative, clearing surface") self.surface = None # Ensure any old surface is cleared if self.canvas: self.canvas.queue_draw() return False + logger.debug(f"{self.__class__.__name__}: Submitting render_to_surface task ({render_width}x{render_height})") # 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 @@ -957,12 +965,14 @@ def draw(self, ctx: cairo.Context): 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. + # Draw the buffered surface. The surface was rendered at screen + # resolution (pixels), but the Cairo context is in element space + # (where element dimensions are self.width x self.height). + # We need to scale from pixel space back to element space. ctx.save() - scale_x = self.width / source_w - scale_y = self.height / source_h - ctx.scale(scale_x, scale_y) + scale_x = source_w / self.width + scale_y = source_h / self.height + ctx.scale(1.0 / scale_x, 1.0 / scale_y) ctx.set_source_surface(self.surface, 0, 0) ctx.get_source().set_filter(cairo.FILTER_GOOD) ctx.paint() diff --git a/rayforge/ui_gtk/canvas2d/elements/simulation_overlay.py b/rayforge/ui_gtk/canvas2d/elements/simulation_overlay.py index 1191a297..eff33d2b 100644 --- a/rayforge/ui_gtk/canvas2d/elements/simulation_overlay.py +++ b/rayforge/ui_gtk/canvas2d/elements/simulation_overlay.py @@ -201,10 +201,11 @@ def draw(self, ctx: cairo.Context): return min_speed, max_speed = self.timeline.speed_range + line_width = self._get_line_width_mm() # Draw each operation with heatmap color and power transparency for cmd, state, start_pos in steps: - ctx.set_line_width(0.2) + ctx.set_line_width(line_width) ctx.set_dash([]) if cmd.is_travel_command(): @@ -233,7 +234,7 @@ def draw(self, ctx: cairo.Context): ctx.line_to(seg_end[0], seg_end[1]) ctx.stroke() elif isinstance(cmd, ScanLinePowerCommand): - self._draw_scanline(ctx, cmd, start_pos, state) + self._draw_scanline(ctx, cmd, start_pos, state, line_width) elif cmd.end: # This handles LineToCommand ctx.move_to(start_pos[0], start_pos[1]) ctx.line_to(cmd.end[0], cmd.end[1]) @@ -242,7 +243,7 @@ def draw(self, ctx: cairo.Context): # Draw laser head position indicator current_pos = self.get_current_position() if current_pos: - self._draw_laser_head(ctx, current_pos) + self._draw_laser_head(ctx, current_pos, line_width) def _draw_scanline( self, @@ -250,6 +251,7 @@ def _draw_scanline( cmd: ScanLinePowerCommand, start_pos: tuple, state: State, + line_width: float, ): """Draws a ScanLinePowerCommand as a series of colored segments.""" if cmd.end is None: @@ -278,35 +280,60 @@ def _draw_scanline( seg_end_pt = p_start + t_end * line_vec ctx.set_source_rgba(r, g, b, alpha) + ctx.set_line_width(line_width) ctx.move_to(seg_start_pt[0], seg_start_pt[1]) ctx.line_to(seg_end_pt[0], seg_end_pt[1]) ctx.stroke() - def _draw_laser_head(self, ctx: cairo.Context, pos: Tuple[float, float]): + def _draw_laser_head( + self, + ctx: cairo.Context, + pos: Tuple[float, float], + line_width: float, + ): """Draws the laser head indicator at the given position in mm.""" x, y = pos # Draw a crosshair with circle ctx.set_source_rgba(1.0, 0.0, 0.0, 0.8) # Red with transparency - ctx.set_line_width(0.2) + ctx.set_line_width(line_width) + + radius = self._get_px_to_mm(9.0) + arm = self._get_px_to_mm(18.0) + dot = self._get_px_to_mm(1.5) - # Circle (3mm radius) - ctx.arc(x, y, 3.0, 0, 2 * 3.14159) + ctx.arc(x, y, radius, 0, 2 * 3.14159) ctx.stroke() - # Crosshair lines (6mm each direction) - ctx.move_to(x - 6.0, y) - ctx.line_to(x + 6.0, y) + ctx.move_to(x - arm, y) + ctx.line_to(x + arm, y) ctx.stroke() - ctx.move_to(x, y - 6.0) - ctx.line_to(x, y + 6.0) + ctx.move_to(x, y - arm) + ctx.line_to(x, y + arm) ctx.stroke() # Center dot - ctx.arc(x, y, 0.5, 0, 2 * 3.14159) + ctx.arc(x, y, dot, 0, 2 * 3.14159) ctx.fill() + def _get_line_width_mm(self) -> float: + if not self.canvas: + return 0.2 + view_ppm_x, view_ppm_y = self.canvas.get_view_scale() + if view_ppm_x <= 1e-9 or view_ppm_y <= 1e-9: + return 0.2 + return 2.0 / max(view_ppm_x, view_ppm_y) + + def _get_px_to_mm(self, px: float) -> float: + if not self.canvas: + return px + view_ppm_x, view_ppm_y = self.canvas.get_view_scale() + ppm = max(view_ppm_x, view_ppm_y) + if ppm <= 1e-9: + return px + return px / ppm + def draw_overlay(self, ctx: cairo.Context): """ Draws overlay elements in pixel space (after view transform). diff --git a/rayforge/ui_gtk/canvas2d/elements/workpiece.py b/rayforge/ui_gtk/canvas2d/elements/workpiece.py index fea909e7..aa451968 100644 --- a/rayforge/ui_gtk/canvas2d/elements/workpiece.py +++ b/rayforge/ui_gtk/canvas2d/elements/workpiece.py @@ -1,4 +1,6 @@ import logging +import math +from concurrent.futures import Future from typing import Optional, TYPE_CHECKING, Dict, Tuple, cast, List, Set, Any import cairo import numpy as np @@ -63,10 +65,16 @@ def __init__( self._base_image_visible = True self._surface: Optional[cairo.ImageSurface] = None + self._ops_surfaces: Dict[ + str, Optional[Tuple[cairo.ImageSurface, Tuple[float, ...]]] + ] = {} + self._ops_recordings: Dict[str, Optional[cairo.RecordingSurface]] = {} self._ops_visibility: Dict[str, bool] = {} + self._ops_render_futures: Dict[str, Future] = {} self._ops_generation_ids: Dict[ str, int ] = {} # Tracks the *expected* generation ID of the *next* render. + self._texture_surfaces: Dict[str, cairo.ImageSurface] = {} # Cached artifacts to avoid re-fetching from pipeline on every draw. self._artifact_cache: Dict[str, Optional[WorkPieceArtifact]] = {} @@ -181,6 +189,9 @@ def _hydrate_from_cache(self) -> bool: # Restore caches. We copy the dictionaries to avoid modification # issues, but the heavy objects (Surfaces) are shared references. self._surface = cache.get("surface") + self._ops_surfaces = cache.get("ops_surfaces", {}).copy() + self._ops_recordings = cache.get("ops_recordings", {}).copy() + self._texture_surfaces = cache.get("texture_surfaces", {}).copy() self._artifact_cache = cache.get("artifact_cache", {}).copy() self._ops_generation_ids = cache.get("ops_generation_ids", {}).copy() @@ -189,7 +200,11 @@ def _hydrate_from_cache(self) -> bool: # to manage across view lifecycles. # Consider hydrated if we have a base surface or artifacts - return self._surface is not None or len(self._artifact_cache) > 0 + return ( + self._surface is not None + or len(self._artifact_cache) > 0 + or len(self._ops_surfaces) > 0 + ) def _update_model_view_cache(self): """ @@ -197,6 +212,9 @@ def _update_model_view_cache(self): """ cache = self.data._view_cache cache["surface"] = self._surface + cache["ops_surfaces"] = self._ops_surfaces + cache["ops_recordings"] = self._ops_recordings + cache["texture_surfaces"] = self._texture_surfaces cache["artifact_cache"] = self._artifact_cache cache["ops_generation_ids"] = self._ops_generation_ids @@ -228,8 +246,6 @@ def trigger_view_update(self): # 1. Invalidate the base image buffer. self._surface = None - # 2. Invalidate only the rasterized ops surfaces, not the recordings. - # 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. @@ -729,6 +745,57 @@ def _on_ops_generation_finished_main_thread( self._artifact_cache[step.uid] = artifact self._update_model_view_cache() + if logger.isEnabledFor(logging.DEBUG) and artifact and artifact.vertex_data: + v_data = artifact.vertex_data + counts = ( + v_data.powered_vertices.size, + v_data.travel_vertices.size, + v_data.zero_power_vertices.size, + ) + bounds = None + try: + stacks = [ + v + for v in ( + v_data.powered_vertices, + v_data.travel_vertices, + v_data.zero_power_vertices, + ) + if v.size > 0 + ] + if stacks: + v_stack = np.vstack(stacks) + v_min = np.min(v_stack, axis=0) + v_max = np.max(v_stack, axis=0) + bounds = (v_min.tolist(), v_max.tolist()) + except Exception as exc: # pragma: no cover - debug only + logger.debug("Failed to compute vertex bounds: %s", exc) + logger.debug( + "Artifact vertices for step '%s': counts powered/travel/zero=%s, bounds=%s, gen_size=%s, source_dims=%s", + step.uid, + counts, + bounds, + artifact.generation_size, + artifact.source_dimensions, + ) + + # Asynchronously prepare texture surface if it exists + if artifact and artifact.texture_data: + if future := self._ops_render_futures.pop(step.uid, None): + future.cancel() + + logger.debug( + f"PRE-submit _prepare_texture_surface_async for '{step.uid}'" + ) + future = self._executor.submit( + self._prepare_texture_surface_async, step.uid, artifact + ) + self._ops_render_futures[step.uid] = future + future.add_done_callback(self._on_texture_surface_prepared) + logger.debug( + f"POST-submit _prepare_texture_surface_async for '{step.uid}'" + ) + # Trigger a view render for this step if progressive rendering # was not already done. If progressive rendering was used, the view # artifact was already created and chunks were drawn to it during @@ -737,6 +804,71 @@ def _on_ops_generation_finished_main_thread( self._request_view_render(step.uid, force=True) self._steps_with_progressive_render.discard(step.uid) + if self.canvas: + self.canvas.queue_draw() + logger.debug( + f"END _on_ops_generation_finished_main_thread for " + f"step '{sender.uid}'" + ) + def _prepare_texture_surface_async( + self, step_uid: str, artifact: WorkPieceArtifact + ) -> Optional[Tuple[str, cairo.ImageSurface]]: + """ + Performs the CPU-intensive conversion of raw texture data to a themed, + pre-multiplied Cairo ImageSurface. Designed to run in a background + thread. + """ + self._resolve_colors_if_needed() + if not self._color_set or not artifact.texture_data: + return None + + power_data = artifact.texture_data.power_texture_data + if power_data.size == 0: + return None + + engrave_lut = self._color_set.get_lut("engrave") + rgba_texture = engrave_lut[power_data] + + # Manually set alpha to 0 where power is 0 for transparency + zero_power_mask = power_data == 0 + rgba_texture[zero_power_mask, 3] = 0.0 + + h, w = rgba_texture.shape[:2] + # Create pre-multiplied BGRA data for Cairo + alpha_ch = rgba_texture[..., 3, np.newaxis] + rgb_ch = rgba_texture[..., :3] + bgra_texture = np.empty((h, w, 4), dtype=np.uint8) + # Pre-multiply RGB by Alpha, then convert to BGRA byte order + premultiplied_rgb = rgb_ch * alpha_ch * 255 + bgra_texture[..., 0] = premultiplied_rgb[..., 2] # B + bgra_texture[..., 1] = premultiplied_rgb[..., 1] # G + bgra_texture[..., 2] = premultiplied_rgb[..., 0] # R + bgra_texture[..., 3] = alpha_ch.squeeze() * 255 # A + + texture_surface = cairo.ImageSurface.create_for_data( + memoryview(np.ascontiguousarray(bgra_texture)), + cairo.FORMAT_ARGB32, + w, + h, + ) + return step_uid, texture_surface + + def _on_texture_surface_prepared(self, future: Future): + """Callback for when the async texture preparation is complete.""" + GLib.idle_add(self._on_texture_surface_prepared_main_thread, future) + + def _on_texture_surface_prepared_main_thread(self, future: Future): + """Thread-safe handler to cache the prepared texture and redraw.""" + if future.cancelled() or future.exception(): + return + result = future.result() + if not result: + return + + step_uid, texture_surface = result + self._texture_surfaces[step_uid] = texture_surface + self._update_model_view_cache() + if self.canvas: self.canvas.queue_draw() @@ -746,6 +878,7 @@ def _draw_vertices_to_context( ctx: cairo.Context, scale: Tuple[float, float], drawable_height: float, + line_width: Optional[float] = None, ): """ Draws vertex data to a Cairo context, handling scaling, theming, @@ -756,10 +889,18 @@ def _draw_vertices_to_context( work_surface = cast("WorkSurface", self.canvas) show_travel = work_surface.show_travel_moves + view_ppm_x, view_ppm_y = work_surface.get_view_scale() + line_width_mm = None + if view_ppm_x > 1e-9 and view_ppm_y > 1e-9: + line_width_mm = 1.0 / max(view_ppm_x, view_ppm_y) scale_x, scale_y = scale ctx.save() - ctx.set_hairline(True) + if line_width is None: + ctx.set_hairline(True) + else: + ctx.set_hairline(False) + ctx.set_line_width(line_width) ctx.set_line_cap(cairo.LINE_CAP_SQUARE) # --- Draw Travel & Zero-Power Moves --- @@ -819,6 +960,522 @@ def _draw_vertices_to_context( ctx.stroke() ctx.restore() + def _record_ops_drawing_async( + self, step: Step, generation_id: int + ) -> Optional[Tuple[str, cairo.RecordingSurface, int]]: + """ + "Draws" the vector data to a RecordingSurface. This captures all vector + commands and is done only when the data changes. + """ + logger.debug( + f"Recording vector data for workpiece " + f"'{self.data.name}', step '{step.uid}'" + ) + artifact = self._artifact_cache.get(step.uid) + if not artifact or not artifact.vertex_data or not self.canvas: + return None + + self._resolve_colors_if_needed() + world_w, world_h = self.data.size + work_surface = cast("WorkSurface", self.canvas) + show_travel = work_surface.show_travel_moves + + # Calculate the union of the workpiece bounds and the vertex bounds to + # ensure the recording surface is large enough. + all_v = [artifact.vertex_data.powered_vertices] + if show_travel: + all_v.append(artifact.vertex_data.travel_vertices) + all_v.append(artifact.vertex_data.zero_power_vertices) + + all_v_filtered = [v for v in all_v if v.size > 0] + if not all_v_filtered: + return None + + v_stack = np.vstack(all_v_filtered) + v_x1, v_y1, _ = np.min(v_stack, axis=0) + v_x2, v_y2, _ = np.max(v_stack, axis=0) + + union_x1 = min(0.0, v_x1) + union_y1 = min(0.0, v_y1) + union_x2 = max(world_w, v_x2) + union_y2 = max(world_h, v_y2) + + union_w = union_x2 - union_x1 + union_h = union_y2 - union_y1 + + if union_w <= 1e-9 or union_h <= 1e-9: + return None + + # Create the recording surface with a small margin to prevent + # strokes on the boundary from being clipped by the recording's + # extents. The extents define the user-space coordinate system. + extents = ( + union_x1 - REC_MARGIN_MM, + union_y1 - REC_MARGIN_MM, + union_w + 2 * REC_MARGIN_MM, + union_h + 2 * REC_MARGIN_MM, + ) + # The pycairo type stubs are incorrect for RecordingSurface; they don't + # specify that a tuple is a valid type for `extents`. We ignore the + # type checker here as the code is functionally correct. + surface = cairo.RecordingSurface( + cairo.CONTENT_COLOR_ALPHA, + extents, # type: ignore + ) + ctx = cairo.Context(surface) + + # We are drawing 1:1 in mm space, so scale is 1.0. The vertex data + # is Y-up, and so is the recording surface's coordinate system. + # So we just pass a height that allows the y-flip to work correctly + # relative to the content we are drawing. + drawable_height_mm = union_y2 + union_y1 + self._draw_vertices_to_context( + artifact.vertex_data, + ctx, + (1.0, 1.0), + drawable_height_mm, + line_width=line_width_mm, + ) + + return step.uid, surface, generation_id + + def _on_ops_drawing_recorded(self, future: Future): + """ + Callback executed when the async ops recording is done. + Schedules the main logic to run on the GTK thread. + """ + GLib.idle_add(self._on_ops_drawing_recorded_main_thread, future) + + def _on_ops_drawing_recorded_main_thread(self, future: Future): + """The thread-safe part of the drawing recorded callback.""" + if future.cancelled(): + return + if exc := future.exception(): + logger.error(f"Error recording ops drawing: {exc}", exc_info=exc) + return + result = future.result() + if not result: + return + + step_uid, recording, received_gen_id = result + + if received_gen_id != self._ops_generation_ids.get(step_uid): + logger.debug( + f"Ignoring stale ops recording for step '{step_uid}'." + ) + return + + logger.debug(f"Applying new ops recording for step '{step_uid}'.") + self._ops_recordings[step_uid] = recording + self._update_model_view_cache() + + # Find the Step object to trigger the initial rasterization. + if self.data.layer and self.data.layer.workflow: + for step_obj in self.data.layer.workflow.steps: + if step_obj.uid == step_uid: + # This call is now safe because we are on the main thread. + self._trigger_ops_rasterization(step_obj, received_gen_id) + return + logger.warning( + "Could not find step '%s' to rasterize after recording.", + step_uid, + ) + + def _trigger_ops_rasterization(self, step: Step, generation_id: int): + """ + Schedules the fast async rasterization of ops using the cached + recording. + """ + step_uid = step.uid + if future := self._ops_render_futures.get(step_uid): + if not future.done(): + future.cancel() # Cancel obsolete render. + + future = self._executor.submit( + self._rasterize_ops_surface_async, step, generation_id + ) + self._ops_render_futures[step_uid] = future + future.add_done_callback(self._on_ops_surface_rendered) + + def _rasterize_ops_surface_async( + self, step: Step, generation_id: int + ) -> Optional[ + Tuple[str, cairo.ImageSurface, int, Tuple[float, float, float, float]] + ]: + """ + Renders ops to an ImageSurface, using the cached RecordingSurface + for a huge speedup if it is available. Also returns the mm bounding + box of the rendered content. + """ + step_uid = step.uid + logger.debug( + f"Rasterizing ops surface for step '{step_uid}', " + f"gen_id {generation_id}" + ) + if not self.canvas: + return None + + self._resolve_colors_if_needed() + recording = self._ops_recordings.get(step_uid) + world_w, world_h = self.data.size + work_surface = cast("WorkSurface", self.canvas) + show_travel = work_surface.show_travel_moves + + # Determine the millimeter dimensions and offset of the content. + if recording: + # FAST PATH: use extents from the recording surface. + extents = recording.get_extents() + if extents: + rec_x, rec_y, rec_w, rec_h = extents + content_x_mm = rec_x + REC_MARGIN_MM + content_y_mm = rec_y + REC_MARGIN_MM + content_w_mm = rec_w - 2 * REC_MARGIN_MM + content_h_mm = rec_h - 2 * REC_MARGIN_MM + else: + logger.warning(f"Could not get extents for '{step_uid}'") + return None + else: + # Slow fallback: calculate bounds from vertex data. + artifact = self._artifact_cache.get(step.uid) + if not artifact or not artifact.vertex_data: + return None + + all_v = [artifact.vertex_data.powered_vertices] + if show_travel: + all_v.append(artifact.vertex_data.travel_vertices) + all_v.append(artifact.vertex_data.zero_power_vertices) + + all_v_filtered = [v for v in all_v if v.size > 0] + if not all_v_filtered: + return None + + v_stack = np.vstack(all_v_filtered) + v_x1, v_y1, _ = np.min(v_stack, axis=0) + v_x2, v_y2, _ = np.max(v_stack, axis=0) + + union_x1 = min(0.0, v_x1) + union_y1 = min(0.0, v_y1) + union_x2 = max(world_w, v_x2) + union_y2 = max(world_h, v_y2) + + content_x_mm = union_x1 + content_y_mm = union_y1 + content_w_mm = union_x2 - union_x1 + content_h_mm = union_y2 - union_y1 + + bbox_mm = (content_x_mm, content_y_mm, content_w_mm, content_h_mm) + view_ppm_x, view_ppm_y = work_surface.get_view_scale() + content_width_px = round(content_w_mm * view_ppm_x) + content_height_px = round(content_h_mm * view_ppm_y) + + surface_width = min( + content_width_px + 2 * OPS_MARGIN_PX, CAIRO_MAX_DIMENSION + ) + surface_height = min( + content_height_px + 2 * OPS_MARGIN_PX, CAIRO_MAX_DIMENSION + ) + + if ( + surface_width <= 2 * OPS_MARGIN_PX + or surface_height <= 2 * OPS_MARGIN_PX + ): + return None + + surface = cairo.ImageSurface( + cairo.FORMAT_ARGB32, surface_width, surface_height + ) + ctx = cairo.Context(surface) + ctx.translate(OPS_MARGIN_PX, OPS_MARGIN_PX) + + if recording: + # FAST PATH: Replay the cached vector drawing commands. + ctx.save() + # 1. Scale context to match mm units. + ctx.scale(view_ppm_x, view_ppm_y) + # 2. The content area's top-left is at (content_x_mm, content_y_mm) + # in world space. Translate the context so that its origin (0,0) + # corresponds to the world's origin (0,0). + ctx.translate(-content_x_mm, -content_y_mm) + # 3. Set the recording as the source. Its internal coordinates + # are already in world mm, so we can now paint it directly. + ctx.set_source_surface(recording, 0, 0) + ctx.paint() + ctx.restore() + else: + # SLOW FALLBACK: No recording yet, render from vertex data. + artifact = self._artifact_cache.get(step.uid) + if not artifact or not artifact.vertex_data: + return None # Should not happen as we checked above + + encoder_ppm_x = ( + content_width_px / content_w_mm if content_w_mm > 1e-9 else 1 + ) + encoder_ppm_y = ( + content_height_px / content_h_mm if content_h_mm > 1e-9 else 1 + ) + ppms = (encoder_ppm_x, encoder_ppm_y) + + # Translate context to draw the union box content correctly. + ctx.translate( + -content_x_mm * encoder_ppm_x, -content_y_mm * encoder_ppm_y + ) + + # Y-flip height must be workpiece height in pixels. + drawable_h_px = world_h * encoder_ppm_y + self._draw_vertices_to_context( + artifact.vertex_data, + ctx, + ppms, + drawable_h_px, + line_width=1.0, + ) + + return step_uid, surface, generation_id, bbox_mm + + def _on_ops_chunk_available( + self, + sender: Step, + workpiece: WorkPiece, + chunk_handle: "BaseArtifactHandle", + generation_id: int, + **kwargs, + ): + """ + Handler for when a chunk of ops is ready for progressive rendering. + This is called from a background thread. It schedules the expensive + encoding work to happen in another background task. + """ + if workpiece is not self.data: + return + + # STALE CHECK: Ignore chunks from a previous generation request. + step_uid = sender.uid + if generation_id != self._ops_generation_ids.get(step_uid): + get_context().artifact_store.release(chunk_handle) + return + + # Offload the CPU-intensive encoding to the thread pool + future = self._executor.submit( + self._encode_chunk_async, sender, chunk_handle + ) + future.add_done_callback(self._on_chunk_encoded) + + def _encode_chunk_async( + self, step: Step, chunk_handle: BaseArtifactHandle + ): + """ + Does the heavy lifting of preparing a surface and encoding an ops + chunk onto it. This is designed to be run in a thread pool. + """ + # This function runs entirely in a background thread. + chunk_artifact = None + try: + prepared = self._prepare_ops_surface_and_context(step) + if prepared: + chunk_artifact = cast( + WorkPieceArtifact, + get_context().artifact_store.get(chunk_handle), + ) + if not chunk_artifact: + return step.uid + + _surface, ctx, ppms, content_h_px = prepared + + # --- Draw texture data from the chunk if it exists --- + if self._color_set and chunk_artifact.texture_data: + power_data = chunk_artifact.texture_data.power_texture_data + if power_data.size > 0: + engrave_lut = self._color_set.get_lut("engrave") + rgba_texture = engrave_lut[power_data] + + # Manually set alpha for transparency + zero_power_mask = power_data == 0 + rgba_texture[zero_power_mask, 3] = 0.0 + + h, w = rgba_texture.shape[:2] + # Create pre-multiplied BGRA data for Cairo + alpha_ch = rgba_texture[..., 3, np.newaxis] + rgb_ch = rgba_texture[..., :3] + bgra_texture = np.empty((h, w, 4), dtype=np.uint8) + premultiplied_rgb = rgb_ch * alpha_ch * 255 + bgra_texture[..., 0] = premultiplied_rgb[..., 2] # B + bgra_texture[..., 1] = premultiplied_rgb[..., 1] # G + bgra_texture[..., 2] = premultiplied_rgb[..., 0] # R + bgra_texture[..., 3] = alpha_ch.squeeze() * 255 # A + + texture_surface = cairo.ImageSurface.create_for_data( + memoryview(np.ascontiguousarray(bgra_texture)), + cairo.FORMAT_ARGB32, + w, + h, + ) + + # Draw the themed texture to the pixel context + _world_w, world_h = self.data.size + pos_mm = chunk_artifact.texture_data.position_mm + dim_mm = chunk_artifact.texture_data.dimensions_mm + encoder_ppm_x, encoder_ppm_y = ppms + + dest_x_px = pos_mm[0] * encoder_ppm_x + dest_w_px = dim_mm[0] * encoder_ppm_x + dest_h_px = dim_mm[1] * encoder_ppm_y + dest_y_px = pos_mm[1] * encoder_ppm_y + + tex_w_px = texture_surface.get_width() + tex_h_px = texture_surface.get_height() + + if tex_w_px > 0 and tex_h_px > 0: + ctx.save() + ctx.translate(dest_x_px, dest_y_px) + # Add half-pixel offset for raster grid alignment + ctx.translate(0.5, 0.5) + ctx.scale( + dest_w_px / tex_w_px, dest_h_px / tex_h_px + ) + ctx.set_source_surface(texture_surface, 0, 0) + ctx.get_source().set_filter(cairo.FILTER_GOOD) + ctx.paint() + ctx.restore() + + # --- Draw vertex data from the chunk if it exists --- + if chunk_artifact.vertex_data: + self._draw_vertices_to_context( + chunk_artifact.vertex_data, + ctx, + ppms, + content_h_px, + line_width=1.0, + ) + finally: + # IMPORTANT: Release the handle in the subprocess to free memory + get_context().artifact_store.release(chunk_handle) + return step.uid + + def _on_chunk_encoded(self, future: Future): + """ + Callback for when a chunk has been encoded. Schedules the final + UI update on the main thread. + """ + GLib.idle_add(self._on_chunk_encoded_main_thread, future) + + def _on_chunk_encoded_main_thread(self, future: Future): + """ + Thread-safe callback that triggers a redraw after a chunk is ready. + """ + if future.cancelled() or future.exception(): + return + # The result is just the step_uid, we don't need it, but we know + # the surface has been updated. + if self.canvas: + self.canvas.queue_draw() + + def _prepare_ops_surface_and_context( + self, step: Step + ) -> Optional[ + Tuple[cairo.ImageSurface, cairo.Context, Tuple[float, float], float] + ]: + """ + Used by chunk rendering. Ensures an ops surface exists for a step, + creating it if necessary. Returns the surface, a transformed context, + scale, and drawable height in pixels. + """ + if not self.canvas: + return None + + self._resolve_colors_if_needed() + step_uid = step.uid + surface_tuple = self._ops_surfaces.get(step_uid) + world_w, world_h = self.data.size + + # If surface doesn't exist (e.g., first chunk), create it. + # Chunk rendering will be clipped to workpiece bounds for now. + if surface_tuple is None: + work_surface = cast("WorkSurface", self.canvas) + view_ppm_x, view_ppm_y = work_surface.get_view_scale() + content_width_px = round(world_w * view_ppm_x) + content_height_px = round(world_h * view_ppm_y) + + surface_width = min( + content_width_px + 2 * OPS_MARGIN_PX, CAIRO_MAX_DIMENSION + ) + surface_height = min( + content_height_px + 2 * OPS_MARGIN_PX, CAIRO_MAX_DIMENSION + ) + + if ( + surface_width <= 2 * OPS_MARGIN_PX + or surface_height <= 2 * OPS_MARGIN_PX + ): + return None + + surface = cairo.ImageSurface( + cairo.FORMAT_ARGB32, surface_width, surface_height + ) + # Store with workpiece bounds. This will be replaced by the + # final render with the correct, larger bounds. + workpiece_bbox = (0.0, 0.0, world_w, world_h) + self._ops_surfaces[step_uid] = (surface, workpiece_bbox) + else: + surface, _ = surface_tuple + + ctx = cairo.Context(surface) + # Set the origin to the top-left of the content area. + ctx.translate(OPS_MARGIN_PX, OPS_MARGIN_PX) + + # Calculate the pixels-per-millimeter and content height for encoder. + content_width_px = surface.get_width() - 2 * OPS_MARGIN_PX + content_height_px = surface.get_height() - 2 * OPS_MARGIN_PX + encoder_ppm_x = content_width_px / world_w if world_w > 1e-9 else 1.0 + encoder_ppm_y = content_height_px / world_h if world_h > 1e-9 else 1.0 + ppms = (encoder_ppm_x, encoder_ppm_y) + + return surface, ctx, ppms, content_height_px + + def _on_ops_surface_rendered(self, future: Future): + """ + Callback executed when the async ops rendering is done. + Schedules the main logic to run on the GTK thread. + """ + # Schedule the actual handler on the main thread + GLib.idle_add(self._on_ops_surface_rendered_main_thread, future) + + def _on_ops_surface_rendered_main_thread(self, future: Future): + """The thread-safe part of the surface rendered callback.""" + if future.cancelled(): + logger.debug("Ops surface render future was cancelled.") + return + if exc := future.exception(): + logger.error( + f"Error rendering ops surface for '{self.data.name}': {exc}", + exc_info=exc, + ) + return + result = future.result() + if not result: + logger.debug("Ops surface render future returned no result.") + return + + step_uid, new_surface, received_generation_id, bbox_mm = result + + # Ignore results from a previous generation request. + if received_generation_id != self._ops_generation_ids.get(step_uid): + logger.debug( + f"Ignoring stale final render for step '{step_uid}'. " + f"Have ID {self._ops_generation_ids.get(step_uid)}, " + f"received {received_generation_id}." + ) + return + + logger.debug( + f"Applying newly rendered ops surface for step '{step_uid}'." + ) + self._ops_surfaces[step_uid] = (new_surface, bbox_mm) + self._update_model_view_cache() # Save to model cache + self._ops_render_futures.pop(step_uid, None) + if self.canvas: + # This call is now safe because we are on the main thread. + self.canvas.queue_draw() + def render_to_surface( self, width: int, height: int ) -> Optional[cairo.ImageSurface]: @@ -845,8 +1502,8 @@ def draw(self, ctx: cairo.Context): return if self.data.layer and self.data.layer.workflow: - # Draw the new progressive bitmaps if available (Phase 4) - self._draw_progressive_views(ctx) + # Draw vector overlay lines with device-stable thickness + self._draw_vector_overlay(ctx) def _draw_progressive_views(self, ctx: cairo.Context): """ @@ -934,6 +1591,51 @@ def _draw_progressive_views(self, ctx: cairo.Context): ctx.restore() + def _draw_vector_overlay(self, ctx: cairo.Context): + """ + Draws vector ops directly at the current zoom with constant pixel + thickness. This avoids re-recording on zoom changes. + """ + if ( + not self.canvas + or not self.data.layer + or not self.data.layer.workflow + ): + return + + self._resolve_colors_if_needed() + if not self._color_set: + return + + world_w, world_h = self.data.size + if world_w <= 1e-9 or world_h <= 1e-9: + return + + work_surface = cast("WorkSurface", self.canvas) + view_ppm_x, view_ppm_y = work_surface.get_view_scale() + if view_ppm_x <= 1e-9 or view_ppm_y <= 1e-9: + return + + line_width_norm = 1.0 / max( + view_ppm_x * world_w, view_ppm_y * world_h + ) + + ctx.save() + for step in self.data.layer.workflow.steps: + if not self._ops_visibility.get(step.uid, True): + continue + artifact = self._artifact_cache.get(step.uid) + if not artifact or not artifact.vertex_data: + continue + self._draw_vertices_to_context( + artifact.vertex_data, + ctx, + (1.0 / world_w, -1.0 / world_h), + 0.0, + line_width=line_width_norm, + ) + ctx.restore() + def push_transform_to_model(self): """Updates the data model's matrix with the view's transform.""" if self.data.matrix != self.transform: diff --git a/rayforge/ui_gtk/canvas2d/surface.py b/rayforge/ui_gtk/canvas2d/surface.py index 8dfe9635..3c92cc1d 100644 --- a/rayforge/ui_gtk/canvas2d/surface.py +++ b/rayforge/ui_gtk/canvas2d/surface.py @@ -22,6 +22,7 @@ from . import context_menu from ..sketcher.editor import SketchEditor from ..sketcher.sketchelement import SketchElement +from ..shared.keyboard import is_primary_modifier if TYPE_CHECKING: from ...doceditor.editor import DocEditor @@ -907,11 +908,11 @@ def on_key_pressed( if super().on_key_pressed(controller, keyval, keycode, state): return True - is_ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + is_primary = is_primary_modifier(state) is_shift = bool(state & Gdk.ModifierType.SHIFT_MASK) # Handle moving workpiece to another layer - if is_ctrl and ( + 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 @@ -919,7 +920,7 @@ def on_key_pressed( return True # Handle clipboard and duplication - if is_ctrl: + if is_primary: selected_items = [e.data for e in self.get_selected_elements()] if keyval == Gdk.KEY_x: if selected_items: diff --git a/rayforge/ui_gtk/canvas3d/axis_renderer_3d.py b/rayforge/ui_gtk/canvas3d/axis_renderer_3d.py index 50234cd0..b066595b 100644 --- a/rayforge/ui_gtk/canvas3d/axis_renderer_3d.py +++ b/rayforge/ui_gtk/canvas3d/axis_renderer_3d.py @@ -257,12 +257,12 @@ def render( # Draw grid and axes line_shader.set_mat4("uMVP", grid_mvp) line_shader.set_vec4("uColor", self.grid_color) - GL.glLineWidth(1.0) + self._set_line_width(1.0) GL.glBindVertexArray(self.grid_vao) GL.glDrawArrays(GL.GL_LINES, 0, self.grid_vertex_count) line_shader.set_vec4("uColor", self.axis_color) - GL.glLineWidth(2.0) + self._set_line_width(2.0) GL.glBindVertexArray(self.axes_vao) GL.glDrawArrays(GL.GL_LINES, 0, self.axes_vertex_count) @@ -292,6 +292,21 @@ def render( ) GL.glDisable(GL.GL_BLEND) + def _set_line_width(self, requested: float) -> None: + try: + width_range = GL.glGetFloatv(GL.GL_ALIASED_LINE_WIDTH_RANGE) + except Exception: + 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) + def _render_axis_labels( self, text_shader: Shader, diff --git a/rayforge/ui_gtk/canvas3d/camera.py b/rayforge/ui_gtk/canvas3d/camera.py index e018837f..56d7ee53 100644 --- a/rayforge/ui_gtk/canvas3d/camera.py +++ b/rayforge/ui_gtk/canvas3d/camera.py @@ -207,7 +207,8 @@ def set_top_view(self, world_width: float, world_depth: float): ) self.target = np.array([center_x, center_y, 0.0], dtype=np.float64) - # Standard orientation: Up vector points along positive Y. + # Keep the camera's up aligned to +Y; scene transforms handle axis + # orientation differences. self.up = np.array([0.0, 1.0, 0.0], dtype=np.float64) # A top-down view should be orthographic, not perspective. diff --git a/rayforge/ui_gtk/canvas3d/canvas3d.py b/rayforge/ui_gtk/canvas3d/canvas3d.py index d5c19cae..18c9adcb 100644 --- a/rayforge/ui_gtk/canvas3d/canvas3d.py +++ b/rayforge/ui_gtk/canvas3d/canvas3d.py @@ -842,6 +842,20 @@ def _on_scene_prepared(self, task: Task): "[CANVAS3D] Scene preparation finished. Caching vertex data." ) self._scene_vtx_cache = task.result() + ( + powered_verts, + _powered_colors, + travel_verts, + zero_power_verts, + _zero_power_colors, + ) = self._scene_vtx_cache + logger.info( + "[CANVAS3D] Vertex counts - powered: %d, travel: %d, " + "zero_power: %d", + powered_verts.size // 3, + travel_verts.size // 3, + zero_power_verts.size // 3, + ) self._update_renderer_from_cache() def _update_renderer_from_cache(self): @@ -852,6 +866,17 @@ def _update_renderer_from_cache(self): if self.ops_renderer: self.ops_renderer.clear() logger.debug("[CANVAS3D] No vertex cache to update renderer from.") + if self.ops_renderer: + ( + powered_verts_dbg, + powered_colors_dbg, + travel_verts_dbg, + ) = self._build_debug_placeholder() + self.ops_renderer.update_from_vertex_data( + powered_verts_dbg, + powered_colors_dbg, + travel_verts_dbg, + ) self.queue_render() return @@ -888,6 +913,25 @@ def _update_renderer_from_cache(self): powered_colors_final, travel_verts_final, ) + + if ( + powered_verts_final.size == 0 + and travel_verts_final.size == 0 + ): + logger.warning( + "[CANVAS3D] Scene vertex data is empty. Rendering " + "debug placeholder line." + ) + ( + powered_verts_dbg, + powered_colors_dbg, + travel_verts_dbg, + ) = self._build_debug_placeholder() + self.ops_renderer.update_from_vertex_data( + powered_verts_dbg, + powered_colors_dbg, + travel_verts_dbg, + ) self.queue_render() def update_scene_from_doc(self): @@ -909,6 +953,10 @@ def update_scene_from_doc(self): # 1. Quickly generate the lightweight scene description scene_description = generate_scene_description(self.doc, self.pipeline) + logger.info( + "[CANVAS3D] Scene description created with %d render items.", + len(scene_description.render_items), + ) # 2. Handle texture instances immediately on the main thread (fast) self.texture_renderer.clear() @@ -957,3 +1005,29 @@ def _schedule_scene_preparation( key=task_key, when_done=self._on_scene_prepared, ) + + def _build_debug_placeholder(self): + """ + Builds a small placeholder line so rendering code paths stay visible + even when no vertex data is produced. + """ + powered_verts = np.array( + [0.0, 0.0, 0.0, 5.0, 0.0, 0.0], dtype=np.float32 + ) + powered_colors = np.array( + [ + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + ], + dtype=np.float32, + ) + travel_verts = np.array( + [0.0, 0.0, 0.02, 5.0, 0.0, 0.02], dtype=np.float32 + ) + return powered_verts, powered_colors, travel_verts diff --git a/rayforge/ui_gtk/canvas3d/gl_utils.py b/rayforge/ui_gtk/canvas3d/gl_utils.py index c4a7b9cf..f5ad6ba6 100644 --- a/rayforge/ui_gtk/canvas3d/gl_utils.py +++ b/rayforge/ui_gtk/canvas3d/gl_utils.py @@ -43,9 +43,12 @@ def __init__(self, vertex_source: str, fragment_source: str): fragment_source = frag_header + fragment_source try: + # Disable validation on macOS to avoid "invalid framebuffer" errors + # during initialization when no framebuffer is bound yet self.program = shaders.compileProgram( shaders.compileShader(vertex_source, GL.GL_VERTEX_SHADER), shaders.compileShader(fragment_source, GL.GL_FRAGMENT_SHADER), + validate=False, ) except Exception as e: logger.error(f"Shader Compilation Failed: {e}", exc_info=True) diff --git a/rayforge/ui_gtk/doceditor/step_settings_dialog.py b/rayforge/ui_gtk/doceditor/step_settings_dialog.py index db32c9b4..ef318c18 100644 --- a/rayforge/ui_gtk/doceditor/step_settings_dialog.py +++ b/rayforge/ui_gtk/doceditor/step_settings_dialog.py @@ -10,6 +10,7 @@ from ...pipeline.transformer import OpsTransformer from ..icons import get_icon from ..shared.adwfix import get_spinrow_float +from ..shared.keyboard import is_primary_modifier from ..shared.patched_dialog_window import PatchedDialogWindow from ..shared.unit_spin_row import UnitSpinRowHelper from .recipe_control_widget import RecipeControlWidget @@ -526,10 +527,10 @@ def _create_tab_title(self, title_str: str, icon_name: str) -> Gtk.Widget: return box def _on_key_pressed(self, controller, keyval, keycode, state): - """Handle key press events, closing the dialog on Escape or Ctrl+W.""" - has_ctrl = state & Gdk.ModifierType.CONTROL_MASK + """Handle key press events, closing the dialog on Escape or Cmd/Ctrl+W.""" + has_primary = is_primary_modifier(state) - if keyval == Gdk.KEY_Escape or (has_ctrl and keyval == Gdk.KEY_w): + if keyval == Gdk.KEY_Escape or (has_primary and keyval == Gdk.KEY_w): self.close() return True return False diff --git a/rayforge/ui_gtk/machine/settings_dialog.py b/rayforge/ui_gtk/machine/settings_dialog.py index 91237539..236de5f5 100644 --- a/rayforge/ui_gtk/machine/settings_dialog.py +++ b/rayforge/ui_gtk/machine/settings_dialog.py @@ -7,6 +7,7 @@ from ...machine.models.machine import Machine from ..camera.camera_preferences_page import CameraPreferencesPage from ..icons import get_icon +from ..shared.keyboard import is_primary_modifier from ..shared.patched_dialog_window import PatchedDialogWindow from .advanced_preferences_page import AdvancedPreferencesPage from .device_settings_page import DeviceSettingsPage @@ -221,10 +222,10 @@ def _sync_camera_page(self, sender=None, **kwargs): self.camera_page.set_controllers(relevant_controllers) def _on_key_pressed(self, controller, keyval, keycode, state): - """Handle key press events, closing the dialog on Escape or Ctrl+W.""" - has_ctrl = state & Gdk.ModifierType.CONTROL_MASK + """Handle key press events, closing the dialog on Escape or Cmd/Ctrl+W.""" + has_primary = is_primary_modifier(state) - if keyval == Gdk.KEY_Escape or (has_ctrl and keyval == Gdk.KEY_w): + if keyval == Gdk.KEY_Escape or (has_primary and keyval == Gdk.KEY_w): self.close() return True return False diff --git a/rayforge/ui_gtk/shared/keyboard.py b/rayforge/ui_gtk/shared/keyboard.py new file mode 100644 index 00000000..b81cd9b1 --- /dev/null +++ b/rayforge/ui_gtk/shared/keyboard.py @@ -0,0 +1,37 @@ +import sys +from gi.repository import Gdk + + +def primary_modifier_mask() -> Gdk.ModifierType: + if sys.platform == "darwin": + return ( + Gdk.ModifierType.META_MASK + | Gdk.ModifierType.SUPER_MASK + | Gdk.ModifierType.MOD2_MASK + ) + return Gdk.ModifierType.CONTROL_MASK + + +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) + + +def primary_accel() -> str: + if sys.platform == "darwin": + return "" + return "" diff --git a/rayforge/ui_gtk/sketcher/editor.py b/rayforge/ui_gtk/sketcher/editor.py index 0c0241fe..c631db74 100644 --- a/rayforge/ui_gtk/sketcher/editor.py +++ b/rayforge/ui_gtk/sketcher/editor.py @@ -8,6 +8,7 @@ from ...core.sketcher.tools.text_box_tool import TextBoxState from ...core.undo import HistoryManager from ..canvas.cursor import get_tool_cursor +from ..shared.keyboard import is_primary_modifier from .piemenu import SketchPieMenu if TYPE_CHECKING: @@ -418,7 +419,7 @@ def handle_key_press( if not self.sketch_element: return False - is_ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) + is_primary = is_primary_modifier(state) is_shift = bool(state & Gdk.ModifierType.SHIFT_MASK) # Priority 0: Active text editing @@ -439,7 +440,7 @@ def handle_key_press( Gdk.KEY_KP_Home: SketcherKey.HOME, Gdk.KEY_KP_End: SketcherKey.END, } - if is_ctrl: + if is_primary: key_map[Gdk.KEY_z] = SketcherKey.UNDO key_map[Gdk.KEY_y] = SketcherKey.REDO key_map[Gdk.KEY_c] = SketcherKey.COPY @@ -448,10 +449,10 @@ def handle_key_press( 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_ctrl + key_map[keyval], shift=is_shift, ctrl=is_primary ) - if is_ctrl: + if is_primary: return False key_unicode = Gdk.keyval_to_unicode(keyval) @@ -459,10 +460,8 @@ def handle_key_press( return tool.handle_text_input(chr(key_unicode)) return False # Unhandled key during text edit - is_ctrl = bool(state & Gdk.ModifierType.CONTROL_MASK) - # Priority 1: Immediate actions (Undo/Redo, Delete) - if is_ctrl: + if is_primary: if keyval == Gdk.KEY_z: self.history_manager.undo() self._reset_key_sequence() diff --git a/rayforge/version.txt b/rayforge/version.txt new file mode 100644 index 00000000..2afd3986 --- /dev/null +++ b/rayforge/version.txt @@ -0,0 +1 @@ +1.0.1-46-gf5ed4c06 diff --git a/rayforge/worker_init.py b/rayforge/worker_init.py index 79d67bf5..efca70fc 100644 --- a/rayforge/worker_init.py +++ b/rayforge/worker_init.py @@ -1,5 +1,8 @@ import builtins import logging +import os +import sys +from pathlib import Path def initialize_worker(): @@ -14,6 +17,23 @@ def initialize_worker(): if not hasattr(builtins, "_"): setattr(builtins, "_", lambda s: s) + if hasattr(sys, "_MEIPASS"): + frameworks_dir = Path(sys._MEIPASS).parent / "Frameworks" + lib_path = str(frameworks_dir) + 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}" + ) + os.environ.setdefault("DYLD_FALLBACK_LIBRARY_PATH", lib_path) + bundled_typelibs = frameworks_dir / "gi_typelibs" + if bundled_typelibs.exists(): + os.environ["GI_TYPELIB_PATH"] = str(bundled_typelibs.resolve()) + bundled_gio_modules = frameworks_dir / "gio_modules" + if bundled_gio_modules.exists(): + os.environ.setdefault( + "GIO_EXTRA_MODULES", str(bundled_gio_modules) + ) + logging.getLogger(__name__).debug( "Worker process initialized successfully." ) diff --git a/requirements.txt b/requirements.txt index 45b2adc5..40adf4a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,12 +6,13 @@ GitPython==3.1.44 numpy==2.3.4 opencv_python platformdirs==4.3.6 +cairosvg==2.8.2 pluggy==1.6.0 pycairo==1.28.0 pyclipper==1.3.0.post6 PyGObject==3.50.0 PyOpenGL==3.1.9 -PyOpenGL_accelerate==3.1.9 +PyOpenGL_accelerate==3.1.10 pypdf==6.6.2 pyserial_asyncio==0.6 pyvips==3.0.0 diff --git a/scripts/mac_build.sh b/scripts/mac_build.sh new file mode 100755 index 00000000..28aa6b8c --- /dev/null +++ b/scripts/mac_build.sh @@ -0,0 +1,489 @@ +#!/usr/bin/env bash +set -euo pipefail + +DO_BUILD=0 +DO_BUNDLE=0 +DO_DMG=0 +VERSION_OVERRIDE="" +GREEN="\033[0;32m" +NC="\033[0m" + +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) Distribution package (.dmg)" +echo " 4) All of the above" +echo " 5) exit" +echo "" +read -r -p "Choice (1-5): " BUILD_CHOICE +case "$BUILD_CHOICE" in + 1) + DO_BUILD=1 + ;; + 2) + DO_BUNDLE=1 + ;; + 3) + DO_DMG=1 + ;; + 4) + DO_BUILD=1 + DO_BUNDLE=1 + DO_DMG=1 + ;; + 5) + exit 0 + ;; + *) + exit 0 + ;; +esac + +if [ ! -f .mac_env ]; then + echo ".mac_env not found. Run scripts/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-mac} +if [ ! -d "$VENV_PATH" ]; then + python3 -m venv "$VENV_PATH" +fi + +ACTIVATED_BY_SCRIPT=0 +if [ -z "${VIRTUAL_ENV:-}" ]; then + source "$VENV_PATH/bin/activate" + ACTIVATED_BY_SCRIPT=1 +fi + +cleanup() { + if (( ACTIVATED_BY_SCRIPT == 1 )); then + deactivate + fi +} + +trap cleanup EXIT + +python -m pip install --upgrade pip +python -m pip install --upgrade build pyinstaller +TMP_REQUIREMENTS=$(mktemp) +grep -v -i '^PyOpenGL_accelerate' requirements.txt > "$TMP_REQUIREMENTS" +python -m pip install -r "$TMP_REQUIREMENTS" +rm -f "$TMP_REQUIREMENTS" +python -m pip install PyOpenGL_accelerate==3.1.10 || \ + echo "PyOpenGL_accelerate install failed; continuing." + +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 "" + python -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 "" + python - <<'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 + # Generate macOS icon if it doesn't exist or if SVG is newer + if [ ! -f "rayforge/resources/icons/icon.icns" ] || \ + [ "website/content/assets/icon.svg" -nt "rayforge/resources/icons/icon.icns" ]; then + echo "Generating macOS icon..." + bash scripts/macos_create_icon.sh + else + echo "Icon is up to date, skipping generation." + fi + + pyinstaller --clean --noconfirm Rayforge.spec + + APP_ROOT="dist/Rayforge.app/Contents" + FW_DIR="$APP_ROOT/Frameworks" + BIN_DIR="$APP_ROOT/MacOS" + + chmod -R u+w "dist/Rayforge.app" || true + + # 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 \ + 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 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" "$FW_DIR"/*.dylib 2>/dev/null | \ + awk '{print $1}' | grep -E '^/usr/local/|^/opt/homebrew/' | \ + sort -u || true) + + return $changed + } + + # 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 + 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 + + # 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 + + # 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/*.tar.gz after app bundle..." + rm -f dist/*.whl 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_setup.sh b/scripts/mac_setup.sh new file mode 100755 index 00000000..113aeb22 --- /dev/null +++ b/scripts/mac_setup.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL=0 +if [[ "${1:-}" == "--install" ]]; then + INSTALL=1 +fi + +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 + +DEPS=( + gtk4 + libadwaita + gobject-introspection + librsvg + libvips + openslide + pkg-config + meson + ninja + cairo + pango + harfbuzz +) + +MISSING=() +for dep in "${DEPS[@]}"; do + if ! brew list --versions "$dep" >/dev/null 2>&1; then + MISSING+=("$dep") + fi +done + +if (( ${#MISSING[@]} > 0 )); then + if (( INSTALL == 1 )); then + brew install "${MISSING[@]}" + else + echo "Missing Homebrew packages: ${MISSING[*]}" >&2 + echo "Re-run with --install to install them automatically." >&2 + exit 1 + fi +fi + +cat > .mac_env < /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/website/content/assets/icon_app.svg b/website/content/assets/icon_app.svg new file mode 100644 index 00000000..11c7644e --- /dev/null +++ b/website/content/assets/icon_app.svg @@ -0,0 +1,858 @@ + + + +