diff --git a/.gitignore b/.gitignore index d7178cd..9d91c27 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,14 @@ dist/ # Generated at build time by scripts/download-python-embed.js resources/python-embed/ resources/get-pip.py +builds/ + +# Local setup/build artifacts +api/python_setup.json +api/texture_baker/build/ +api/uv_unwrapper/build/ +api/texture_baker/texture_baker/_C*.so +api/uv_unwrapper/uv_unwrapper/_C*.so # Python api/__pycache__/ @@ -32,4 +40,3 @@ Thumbs.db # Env .env .env.local - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ff9f4c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,94 @@ +# Changelog + +## v0.1.4-dev (2026-03-22) + +### AMD GPU (ROCm) Support + +- **Switched PyTorch index to ROCm 6.4** for AMD GPU support (RDNA 4 / gfx1201 tested on RX 9070 XT) +- **Replaced `onnxruntime-gpu` with `onnxruntime`** in requirements.txt — the GPU variant is NVIDIA-only; CPU ONNX runtime works fine for `rembg` background removal +- **GPU auto-detection at install time** (`electron/main/python-setup.ts`): checks `nvidia-smi` (NVIDIA), `rocminfo` (AMD ROCm), or `wmic` (Windows fallback), and passes the correct `--index-url` to pip: + - NVIDIA: `https://download.pytorch.org/whl/cu128` + - AMD: `https://download.pytorch.org/whl/rocm6.4` + - No GPU: `https://download.pytorch.org/whl/cpu` +- **Removed hardcoded `--index-url` from `api/requirements.txt`** — the URL is now injected by `python-setup.ts` at runtime based on detected GPU + +### Dev Mode Setup (First-Run Install) + +- **Dev mode no longer skips setup** (`electron/main/ipc-handlers.ts`): previously `setup:check` returned `{ needed: false }` when `!app.isPackaged`, meaning the venv and pip install never ran. Now it checks for the existence of `api/.venv/bin/python` (Unix) or `api/.venv/Scripts/python.exe` (Windows) +- **Dev mode creates venv at `api/.venv`** (`electron/main/python-setup.ts`): matches where `resolvePythonExecutable()` looks for it in dev mode +- **Windows dev mode support** (`electron/main/python-setup.ts`): added a new branch in `runFullSetup()` for `win32 && !app.isPackaged` that uses `findSystemPython()` and creates a venv with `Scripts/python.exe` +- **`findSystemPython()` prefers Python 3.12** over 3.14+: the candidate list (`python3.12`, `python3.11`, `python3.10`, `python3`, `python`) ensures PyTorch-compatible Python is selected even on systems where `python3` points to 3.14+ + +### C++ Extension Compilation + +- **Automatic compilation of `texture_baker` and `uv_unwrapper`** during first-run setup (`electron/main/python-setup.ts`): added `buildCppExtensions()` that runs `setup.py build_ext --inplace` for both extensions after pip install completes. Failures are non-fatal (texture features disabled but app still works) +- **Added "Compiling extensions" step** to the setup UI (`src/areas/setup/FirstRunSetup.tsx`) + +### Linux Packaged Build (AppImage) + +- **Bundled Python 3.11.9 in Linux AppImage** (`package.json`): added `extraResources` for `python-embed` to the `linux` build config — previously only the `win` config included it, so the AppImage had no Python and setup would fail with ENOENT +- **Fixed `getEmbeddedPythonExe()` symlink handling** (`electron/main/python-setup.ts`): python-build-standalone uses symlinks (`python3` -> `python3.11`) which may not survive AppImage packaging. Now tries `bin/python3.11`, `bin/python3`, `bin/python` in order + +### Setup UI Improvements + +- **Added missing progress steps** to `FirstRunSetup.tsx`: "Finding Python", "Creating environment", and "Compiling extensions" now show in the setup progress indicator alongside the existing "Preparing Python", "Installing pip", and "Installing packages" steps + +### Extension System + +- **Allow downloading models for unverified extensions** (`src/areas/models/components/ExtensionCard.tsx`): previously the Install button was disabled for extensions without a signature in the official registry. Now any extension can download model weights (the "Unverified" badge still shows as a warning) +- **Added `GenerationCancelled` exception and `_check_cancelled()` method** to `BaseGenerator` (`api/services/generators/base.py`): required by newer extension versions that support cancellation during generation + +### New Dependencies + +Added to `api/requirements.txt`: +- `omegaconf>=2.3.0` — required by `hy3dshape` (Hunyuan3D 2.1 pipeline) +- `timm>=1.0.0` — required by `hy3dshape` (vision transformer for image encoding) +- `torchdiffeq>=0.2.5` — required by `hy3dshape` (ODE solver for flow matching) +- `pybind11>=2.12.0` — required to compile C++ extensions (`differentiable_renderer`) + +### Custom Extensions Created + +Created two custom extensions in `~/.config/Modly/extensions/` (user data, not in repo): + +- **`hunyuan3d/`** — Full Hunyuan3D 2 model (3.3B params) with Standard/Turbo/Fast variants from `tencent/Hunyuan3D-2`. Uses `hy3dgen` pipeline, supports texture generation +- **`hunyuan3d-2.1/`** — Hunyuan3D 2.1 (latest) from `tencent/Hunyuan3D-2.1`. Uses `hy3dshape` pipeline, shape-only (PBR texture model requires ~21 GB VRAM) + +### Build System + +- **Linux AppImage** built and placed in `builds/linux/Modly-0.1.3.AppImage` +- **Windows NSIS installer** cross-compiled and placed in `builds/windows/Modly Setup 0.1.3.exe` + +### Files Modified + +| File | Changes | +|------|---------| +| `api/requirements.txt` | Removed hardcoded CUDA index URL, added ROCm-compatible deps, added omegaconf/timm/torchdiffeq/pybind11 | +| `electron/main/python-setup.ts` | GPU detection, Windows dev mode, C++ extension compilation, embedded Python symlink handling | +| `electron/main/ipc-handlers.ts` | Dev mode setup check for both Unix and Windows, dev mode setup:run passes correct userData path | +| `electron/main/python-bridge.ts` | No changes (already handled dev/packaged correctly) | +| `src/areas/setup/FirstRunSetup.tsx` | Added python/venv/extensions steps to progress UI | +| `src/areas/models/components/ExtensionCard.tsx` | Removed trusted-only gate on Install button | +| `api/services/generators/base.py` | Added `GenerationCancelled` exception class and `_check_cancelled()` method | +| `package.json` | Added `extraResources` for python-embed to Linux build config | + +### Known Limitations + +- **Hunyuan3D 2.1 texture generation** requires ~21 GB VRAM (PBR paint model) — not feasible on 16 GB cards. Shape-only generation works fine +- **Hunyuan3D Mini texture generation** requires the `hy3dgen` texgen C++ extensions (`custom_rasterizer`, `differentiable_renderer`) to be compiled from the downloaded model's `_hy3dgen/` source. These are NOT compiled automatically by the app — they must be built manually with `pip install --no-build-isolation .` from each extension directory. ROCm users also need `hipsparse`, `hipblaslt`, `hipsolver`, `hipcub`, and `rocthrust` system packages +- **Python 3.14 is not supported** by PyTorch — users on Arch Linux (or similar rolling-release distros) need Python 3.10-3.12 installed separately +- **Windows builds are unsigned** — users will see a SmartScreen warning on first run + +### System Requirements Tested + +- **OS**: Arch Linux (kernel 6.19.8) +- **GPU**: AMD Radeon RX 9070 XT (gfx1201, RDNA 4) +- **ROCm**: 7.2.0 with HIP runtime +- **Python**: 3.12.13 (system), 3.11.9 (bundled) +- **PyTorch**: 2.9.1+rocm6.4 + +--- + +## v0.1.3 (previous release) + +- Fix: handle missing file path on drag and drop +- Fix: use requirements.txt hash to trigger reinstall diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9130179 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md — Project Context for Claude Code + +## Project Overview + +Modly is an Electron + React + Python FastAPI desktop app for AI-powered image-to-3D mesh generation. It supports NVIDIA (CUDA), AMD (ROCm), and CPU-only setups. + +## Architecture + +- **Frontend**: Electron 33 + React 18 + TypeScript + Three.js (Vite build) +- **Backend**: Python FastAPI on port 8765, spawned by Electron as a child process +- **Extensions**: Model adapters live in `~/.config/Modly/extensions/` (each has `manifest.json` + `generator.py`) +- **Models**: Downloaded from HuggingFace to `~/.config/Modly/models/` + +## Key Files + +| File | Purpose | +|------|---------| +| `electron/main/python-setup.ts` | First-run setup: GPU detection, venv creation, pip install, C++ extension compilation | +| `electron/main/python-bridge.ts` | Spawns/manages the FastAPI Python process | +| `electron/main/ipc-handlers.ts` | IPC between renderer and main process, setup triggers | +| `api/requirements.txt` | Python dependencies (GPU-agnostic — index URL injected at runtime) | +| `api/services/generator_registry.py` | Discovers and loads extensions dynamically | +| `api/services/generators/base.py` | BaseGenerator ABC with GenerationCancelled, smooth_progress, _check_cancelled | +| `src/areas/setup/FirstRunSetup.tsx` | Setup progress UI | +| `src/areas/models/components/ExtensionCard.tsx` | Extension install UI | +| `package.json` | Build config — `extraResources` for both win and linux include python-embed | + +## GPU Detection + +`python-setup.ts` detects GPU at install time and passes `--index-url` to pip: +- NVIDIA (`nvidia-smi`): `cu128` +- AMD (`rocminfo`): `rocm6.4` +- None: `cpu` + +The `requirements.txt` has NO `--index-url` — only `--extra-index-url https://pypi.org/simple`. + +## Python Version + +- Bundled: Python 3.11.9 (python-build-standalone for Linux, embeddable for Windows) +- Dev mode: Uses system Python — prefers 3.12 > 3.11 > 3.10 > 3.x (3.14+ not supported by PyTorch) +- `getEmbeddedPythonExe()` tries `python3.11`, `python3`, `python` in order (symlinks may not survive packaging) + +## C++ Extensions + +Two app-level extensions compiled during setup: +- `api/texture_baker/` — rasterizes barycentric coordinates for texture baking +- `api/uv_unwrapper/` — UV unwrapping for mesh texturing + +The `hy3dgen` texture pipeline (used by Hunyuan3D Mini) has its own extensions (`custom_rasterizer`, `differentiable_renderer`) that must be compiled separately from the model's `_hy3dgen/` source directory using `pip install --no-build-isolation .` + +AMD ROCm users need these system packages for C++ compilation: `hipsparse`, `hipblaslt`, `hipsolver`, `hipcub`, `rocthrust`. + +## Build Commands + +```bash +npm install # JS dependencies +npm run build # Build Electron app (electron-vite) +npm run preview # Launch in dev mode +npm run prepare-resources # Download bundled Python + +# Release builds +npx electron-builder --linux AppImage +npx electron-builder --win nsis --x64 # Needs Windows python-embed in resources/ +``` + +For Windows cross-compilation from Linux: swap `resources/python-embed` to the Windows embeddable package before building, swap back after. + +## Extension Structure + +``` +~/.config/Modly/extensions// +├── manifest.json # id, name, generator_class, models[], hf_repo, etc. +└── generator.py # Class extending BaseGenerator from services.generators.base +``` + +Extensions import from `services.generators.base` (BaseGenerator, smooth_progress, GenerationCancelled). + +## Known Limitations + +- Hunyuan3D 2.1 texture generation requires ~21 GB VRAM (shape-only works fine) +- Python 3.14+ not supported by PyTorch +- Windows builds are unsigned (SmartScreen warning) +- hy3dgen texgen C++ extensions are NOT auto-compiled by the app setup diff --git a/api/requirements.txt b/api/requirements.txt index 4e3f646..ac07a96 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,7 +1,5 @@ -# PyTorch CUDA — index primaire pour torch/torchvision -# cu128 = CUDA 12.8 (driver ≥520, RTX 30xx/40xx/50xx) -# Changer cu128 → cu126 si le driver est plus ancien ---index-url https://download.pytorch.org/whl/cu128 +# PyTorch index URL is set automatically at install time based on detected GPU. +# Do NOT add --index-url here — it is passed by python-setup.ts. --extra-index-url https://pypi.org/simple # Web server @@ -21,13 +19,17 @@ pillow>=11.0.0 trimesh>=4.5.0 numpy>=1.26.0 rembg>=2.0.0 # background removal (SF3D + Hunyuan3D) -onnxruntime-gpu>=1.17.0 # runtime ONNX requis par rembg (GPU-accelerated) +onnxruntime>=1.17.0 # runtime ONNX requis par rembg (CPU — GPU variant is NVIDIA-only) einops>=0.7.0 # tensor ops used by Hunyuan3D / hy3dshape pipeline pymeshlab>=2023.12 # mesh processing required by hy3dshape postprocessors xatlas>=0.0.8 # UV unwrapping for Hunyuan3D texture generation pygltflib>=1.15.0 # GLB/GLTF I/O for Hunyuan3D texture pipeline opencv-python-headless>=4.8.0 # required by hy3dgen.shapegen.preprocessors (cv2) +omegaconf>=2.3.0 # required by hy3dshape (Hunyuan3D 2.1) +timm>=1.0.0 # required by hy3dshape (vision transformer) +torchdiffeq>=0.2.5 # required by hy3dshape (ODE solver) +pybind11>=2.12.0 # required to compile C++ extensions (differentiable_renderer) # Utils aiofiles==24.1.0 diff --git a/api/services/generators/base.py b/api/services/generators/base.py index fdcb32a..fb983e8 100644 --- a/api/services/generators/base.py +++ b/api/services/generators/base.py @@ -7,6 +7,11 @@ from typing import Callable, Optional +class GenerationCancelled(Exception): + """Raised when the user cancels a running generation.""" + pass + + def smooth_progress( progress_cb: Callable[[int, str], None], start: int, @@ -142,6 +147,11 @@ def _auto_download(self) -> None: # Helpers # ------------------------------------------------------------------ # + def _check_cancelled(self, cancel_event: Optional[threading.Event]) -> None: + """Raises GenerationCancelled if the cancel event is set.""" + if cancel_event is not None and cancel_event.is_set(): + raise GenerationCancelled("Generation was cancelled by the user.") + def _report( self, progress_cb: Optional[Callable[[int, str], None]], diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index b2bf758..8a81e3e 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -30,15 +30,23 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe }) ipcMain.on('window:close', () => getWindow()?.close()) - // Setup handlers — skipped in dev (uses .venv instead of python-embed) + // Setup handlers — in dev mode, check for api/.venv; in packaged mode, use userData venv ipcMain.handle('setup:check', async () => { - if (!app.isPackaged) return { needed: false } + if (!app.isPackaged) { + const apiDir = join(app.getAppPath(), 'api') + const venvPython = process.platform === 'win32' + ? join(apiDir, '.venv', 'Scripts', 'python.exe') + : join(apiDir, '.venv', 'bin', 'python') + return { needed: !existsSync(venvPython) } + } const userData = app.getPath('userData') return { needed: checkSetupNeeded(userData) } }) ipcMain.handle('setup:run', async () => { - const userData = app.getPath('userData') + const userData = !app.isPackaged + ? join(app.getAppPath(), 'api') + : app.getPath('userData') const win = getWindow() if (!win) return { success: false, error: 'No window available' } try { diff --git a/electron/main/python-setup.ts b/electron/main/python-setup.ts index 09c0fd4..8a5f5fb 100644 --- a/electron/main/python-setup.ts +++ b/electron/main/python-setup.ts @@ -1,7 +1,7 @@ import { BrowserWindow, app } from 'electron' import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs' import { join } from 'path' -import { spawn, execSync } from 'child_process' +import { spawn, spawnSync, execSync } from 'child_process' import { createHash } from 'crypto' const SETUP_VERSION = 2 @@ -70,6 +70,13 @@ export function getEmbeddedPythonDir(): string { export function getEmbeddedPythonExe(): string { const dir = getEmbeddedPythonDir() if (process.platform === 'win32') return join(dir, 'python.exe') + // python-build-standalone uses symlinks which may not survive packaging; + // try the versioned binary first, then fallback to python3/python + const candidates = ['bin/python3.11', 'bin/python3', 'bin/python'] + for (const candidate of candidates) { + const p = join(dir, candidate) + if (existsSync(p)) return p + } return join(dir, 'bin', 'python3') } @@ -109,6 +116,55 @@ function installPip(pythonExe: string, resourcesPath: string, win: BrowserWindow }) } +// ─── GPU detection ────────────────────────────────────────────────────────── + +type GpuVendor = 'nvidia' | 'amd' | 'none' + +function detectGpu(): GpuVendor { + // NVIDIA: check for nvidia-smi + try { + execSync('nvidia-smi', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }) + console.log('[PythonSetup] Detected NVIDIA GPU') + return 'nvidia' + } catch { /* not nvidia */ } + + // AMD ROCm: check for rocminfo or /opt/rocm + try { + execSync('rocminfo', { encoding: 'utf8', timeout: 5000, stdio: 'pipe' }) + console.log('[PythonSetup] Detected AMD GPU (ROCm)') + return 'amd' + } catch { /* not amd */ } + + // Windows: check for AMD via WMIC/PowerShell + if (process.platform === 'win32') { + try { + const out = execSync( + 'wmic path win32_VideoController get name', + { encoding: 'utf8', timeout: 5000, stdio: 'pipe' } + ) + if (/radeon|amd/i.test(out)) { + console.log('[PythonSetup] Detected AMD GPU (Windows)') + return 'amd' + } + if (/nvidia|geforce|rtx|gtx|quadro/i.test(out)) { + console.log('[PythonSetup] Detected NVIDIA GPU (Windows)') + return 'nvidia' + } + } catch { /* ignore */ } + } + + console.log('[PythonSetup] No GPU detected, using CPU-only PyTorch') + return 'none' +} + +function getPytorchIndexUrl(vendor: GpuVendor): string { + switch (vendor) { + case 'nvidia': return 'https://download.pytorch.org/whl/cu128' + case 'amd': return 'https://download.pytorch.org/whl/rocm6.4' + case 'none': return 'https://download.pytorch.org/whl/cpu' + } +} + // ─── Shared helper ─────────────────────────────────────────────────────────── function installRequirements( @@ -117,10 +173,13 @@ function installRequirements( win: BrowserWindow ): Promise { return new Promise((resolve, reject) => { + const gpu = detectGpu() + const indexUrl = getPytorchIndexUrl(gpu) + console.log(`[PythonSetup] GPU vendor: ${gpu}, PyTorch index: ${indexUrl}`) console.log('[PythonSetup] Installing requirements from', requirementsPath) const proc = spawn( pythonExe, - ['-m', 'pip', 'install', '-r', requirementsPath, '--no-warn-script-location', '--progress-bar', 'off'], + ['-m', 'pip', 'install', '-r', requirementsPath, '--index-url', indexUrl, '--no-warn-script-location', '--progress-bar', 'off'], { stdio: ['ignore', 'pipe', 'pipe'] } ) let packagesInstalled = 0 @@ -162,38 +221,100 @@ function installRequirements( // ─── Unix helpers (venv) ───────────────────────────────────────────────────── +function probePython(cmd: string): { ok: boolean; version?: string; reason?: string } { + try { + const versionProbe = spawnSync( + cmd, + ['-c', 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")'], + { encoding: 'utf8', timeout: 3000 } + ) + if (versionProbe.status !== 0) { + const err = (versionProbe.stderr || versionProbe.stdout || '').trim() || 'failed to run' + return { ok: false, reason: err } + } + + const version = (versionProbe.stdout || '').trim() + const [majorRaw, minorRaw] = version.split('.') + const major = Number(majorRaw) + const minor = Number(minorRaw) + if (major !== 3 || Number.isNaN(minor) || minor < 10 || minor > 13) { + return { ok: false, reason: `unsupported Python ${version} (need 3.10-3.13)` } + } + + const venvProbe = spawnSync(cmd, ['-m', 'venv', '--help'], { + encoding: 'utf8', + timeout: 3000, + stdio: ['ignore', 'pipe', 'pipe'], + }) + if (venvProbe.status !== 0) { + const err = (venvProbe.stderr || venvProbe.stdout || '').trim() || 'venv module is unavailable' + return { ok: false, reason: err } + } + + return { ok: true, version } + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : String(err) } + } +} + function findSystemPython(): string { const candidates = ['python3.12', 'python3.11', 'python3.10', 'python3', 'python'] for (const cmd of candidates) { - try { - const out = execSync(`${cmd} --version`, { encoding: 'utf8', timeout: 3000 }).trim() - if (out.startsWith('Python 3.')) { - console.log(`[PythonSetup] Found system Python: ${cmd} → ${out}`) - return cmd - } - } catch { /* not found, try next */ } + const probe = probePython(cmd) + if (!probe.ok) { + console.log(`[PythonSetup] Skipping ${cmd}: ${probe.reason}`) + continue + } + console.log(`[PythonSetup] Found system Python: ${cmd} -> Python ${probe.version}`) + return cmd } + throw new Error( - 'Python 3 not found on your system.\n' + - 'Please install Python 3.10+ and try again.\n' + - 'Ubuntu/Debian : sudo apt install python3 python3-venv\n' + - 'macOS : brew install python@3.11' + 'No supported Python interpreter with venv support was found.\n' + + 'Install Python 3.10-3.13 and ensure the venv module is available.\n' + + 'Ubuntu/Debian : sudo apt install python3.12 python3.12-venv\n' + + 'Arch Linux : sudo pacman -S python312\n' + + 'Windows : install Python 3.12 from python.org (enable Add to PATH)\n' + + 'macOS : brew install python@3.12' ) } -function createVenv(python3: string, venvDir: string, win: BrowserWindow): Promise { +function createVenv( + python3: string, + venvDir: string, + win: BrowserWindow, + opts?: { clear?: boolean; copies?: boolean } +): Promise { return new Promise((resolve, reject) => { win.webContents.send('setup:progress', { step: 'venv', percent: 10 }) console.log('[PythonSetup] Creating venv at', venvDir) - const proc = spawn(python3, ['-m', 'venv', venvDir], { stdio: ['ignore', 'pipe', 'pipe'] }) + const args = ['-m', 'venv'] + if (opts?.clear) args.push('--clear') + if (opts?.copies) args.push('--copies') + args.push(venvDir) + const proc = spawn(python3, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stderr = '' proc.stdout?.on('data', (d: Buffer) => console.log('[venv]', d.toString().trim())) - proc.stderr?.on('data', (d: Buffer) => console.error('[venv]', d.toString().trim())) + proc.stderr?.on('data', (d: Buffer) => { + const text = d.toString().trim() + if (text) { + stderr += `${text}\n` + console.error('[venv]', text) + } + }) proc.on('close', (code) => { if (code === 0) { win.webContents.send('setup:progress', { step: 'venv', percent: 20 }) resolve() } else { - reject(new Error(`python3 -m venv exited with code ${code}`)) + const details = stderr.trim() + let hint = '' + if (/No module named venv|ensurepip is not available/i.test(details)) { + hint = + '\nHint: your Python is missing venv/ensurepip support. ' + + 'Install the OS venv package or use Python 3.12 from python.org.' + } + reject(new Error(`${python3} -m venv exited with code ${code}${details ? `\n${details}` : ''}${hint}`)) } }) }) @@ -204,37 +325,52 @@ function createVenv(python3: string, venvDir: string, win: BrowserWindow): Promi export async function runFullSetup(win: BrowserWindow, userData: string): Promise { try { const requirementsPath = getRequirementsPath() + let pythonExe: string - if (process.platform === 'win32') { - // Windows: use embedded Python bundled with the app + if (process.platform === 'win32' && app.isPackaged) { + // Windows packaged: use embedded Python bundled with the app const pythonDir = getEmbeddedPythonDir() - const pythonExe = getEmbeddedPythonExe() - const resourcesPath = app.isPackaged - ? process.resourcesPath - : join(app.getAppPath(), 'resources') + pythonExe = getEmbeddedPythonExe() + const resourcesPath = process.resourcesPath enableSitePackages(pythonDir, win) await installPip(pythonExe, resourcesPath, win) await installRequirements(pythonExe, requirementsPath, win) + } else if (process.platform === 'win32' && !app.isPackaged) { + // Windows dev: create a venv using the system Python + win.webContents.send('setup:progress', { step: 'python', percent: 5 }) + const python3 = findSystemPython() + const venvDir = join(userData, '.venv') + await createVenv(python3, venvDir, win) + pythonExe = join(venvDir, 'Scripts', 'python.exe') + await installRequirements(pythonExe, requirementsPath, win) } else if (app.isPackaged) { // Linux / macOS packaged: use bundled Python to create a venv in userData // (resources dir may be read-only inside .app bundle) win.webContents.send('setup:progress', { step: 'venv', percent: 5 }) const python3 = getEmbeddedPythonExe() const venvDir = join(userData, 'venv') - await createVenv(python3, venvDir, win) - const venvPython = join(venvDir, 'bin', 'python') - await installRequirements(venvPython, requirementsPath, win) + // AppImage and similar bundles can make absolute interpreter symlinks unstable + // across launches; use real copies and clear stale/broken envs. + await createVenv(python3, venvDir, win, { clear: true, copies: true }) + pythonExe = join(venvDir, 'bin', 'python') + await installRequirements(pythonExe, requirementsPath, win) } else { // Linux / macOS dev: create a venv using the system Python win.webContents.send('setup:progress', { step: 'python', percent: 5 }) const python3 = findSystemPython() - const venvDir = join(userData, 'venv') + const venvDir = join(userData, '.venv') await createVenv(python3, venvDir, win) - const venvPython = join(venvDir, 'bin', 'python') - await installRequirements(venvPython, requirementsPath, win) + pythonExe = join(venvDir, 'bin', 'python') + await installRequirements(pythonExe, requirementsPath, win) } + // Compile C++ extensions (texture_baker and uv_unwrapper) + const apiDir = app.isPackaged + ? join(process.resourcesPath, 'api') + : join(app.getAppPath(), 'api') + await buildCppExtensions(pythonExe, apiDir, win) + win.webContents.send('setup:complete') console.log('[PythonSetup] Setup complete') } catch (err) { @@ -244,3 +380,51 @@ export async function runFullSetup(win: BrowserWindow, userData: string): Promis throw err } } + +// ─── C++ extension compilation ────────────────────────────────────────────── + +function buildCppExtension( + pythonExe: string, + extensionDir: string, + name: string, + win: BrowserWindow +): Promise { + return new Promise((resolve, reject) => { + if (!existsSync(join(extensionDir, 'setup.py'))) { + console.log(`[PythonSetup] Skipping ${name}: no setup.py found`) + resolve() + return + } + console.log(`[PythonSetup] Compiling C++ extension: ${name}`) + win.webContents.send('setup:progress', { + step: 'extensions', + currentPackage: `Compiling ${name}…`, + }) + const proc = spawn( + pythonExe, + ['setup.py', 'build_ext', '--inplace'], + { cwd: extensionDir, stdio: ['ignore', 'pipe', 'pipe'] } + ) + proc.stdout?.on('data', (d: Buffer) => console.log(`[${name}]`, d.toString().trim())) + proc.stderr?.on('data', (d: Buffer) => { + const text = d.toString().trim() + if (text) console.warn(`[${name}]`, text) + }) + proc.on('close', (code) => { + if (code === 0) { + console.log(`[PythonSetup] ${name} compiled successfully`) + resolve() + } else { + // Non-fatal: texture features will be disabled but the app still works + console.warn(`[PythonSetup] ${name} compilation failed (code ${code}). Texture features may be unavailable.`) + resolve() + } + }) + }) +} + +async function buildCppExtensions(pythonExe: string, apiDir: string, win: BrowserWindow): Promise { + win.webContents.send('setup:progress', { step: 'extensions', percent: 95 }) + await buildCppExtension(pythonExe, join(apiDir, 'texture_baker'), 'texture_baker', win) + await buildCppExtension(pythonExe, join(apiDir, 'uv_unwrapper'), 'uv_unwrapper', win) +} diff --git a/package-lock.json b/package-lock.json index 9af5939..7d38b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "local-meshy", - "version": "0.1.0", + "name": "modly", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "local-meshy", - "version": "0.1.0", + "name": "modly", + "version": "0.1.3", "dependencies": { "@electron-toolkit/utils": "^4.0.0", "@react-three/drei": "^9.120.0", diff --git a/package.json b/package.json index 27de181..d05cfdc 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,13 @@ }, "linux": { "target": "AppImage", - "icon": "resources/icons/icon.png" + "icon": "resources/icons/icon.png", + "extraResources": [ + { + "from": "resources/python-embed", + "to": "python-embed" + } + ] } } } diff --git a/src/areas/models/components/ExtensionCard.tsx b/src/areas/models/components/ExtensionCard.tsx index d7f26c5..1485ca4 100644 --- a/src/areas/models/components/ExtensionCard.tsx +++ b/src/areas/models/components/ExtensionCard.tsx @@ -138,11 +138,11 @@ export function ExtensionCard({ ext, installedIds, downloading, loadError, disab ) : (