Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 49 additions & 26 deletions api/routers/optimize.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
import tempfile
import uuid

import pymeshlab
try:
import pymeshlab as _pymeshlab
_PYMESHLAB_AVAILABLE = True
except ImportError:
_pymeshlab = None
_PYMESHLAB_AVAILABLE = False

import trimesh
import trimesh.visual
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import Response
from fastapi.responses import FileResponse, Response
from pathlib import Path
from urllib.parse import quote
from pydantic import BaseModel

from services.generator_registry import WORKSPACE_DIR
Expand All @@ -26,8 +34,14 @@ class SmoothRequest(BaseModel):
iterations: int


def _require_pymeshlab():
if not _PYMESHLAB_AVAILABLE:
raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)")


@router.post("/mesh")
def optimize_mesh(body: OptimizeRequest):
_require_pymeshlab()
target_faces = max(100, min(500_000, body.target_faces))

# Security: prevent path traversal
Expand Down Expand Up @@ -70,7 +84,7 @@ def _decimate(input_path: str, target_faces: int, tmp_dir: str) -> trimesh.Trime
else:
geom = loaded

ms = pymeshlab.MeshSet()
ms = _pymeshlab.MeshSet()

if _has_texture(geom):
# ── Textured path: OBJ intermediate to preserve UV coordinates ──────
Expand Down Expand Up @@ -129,6 +143,7 @@ def _decimate(input_path: str, target_faces: int, tmp_dir: str) -> trimesh.Trime

@router.post("/smooth")
def smooth_mesh(body: SmoothRequest):
_require_pymeshlab()
iterations = max(1, min(20, body.iterations))

input_path = (WORKSPACE_DIR / body.path).resolve()
Expand Down Expand Up @@ -160,7 +175,7 @@ def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh:
else:
geom = loaded

ms = pymeshlab.MeshSet()
ms = _pymeshlab.MeshSet()

if _has_texture(geom):
obj_in = os.path.join(tmp_dir, "input.obj")
Expand Down Expand Up @@ -199,32 +214,40 @@ def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh:
return trimesh.load(ply_out, force="mesh")


@router.post("/import")
async def import_mesh(file: UploadFile = File(...)):
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
if ext not in ("glb", "obj", "stl", "ply"):
raise HTTPException(400, f"Unsupported format: {ext}")
class ImportByPathRequest(BaseModel):
path: str # absolute path on disk

collection = f"import_{uuid.uuid4().hex[:8]}"
collection_dir = WORKSPACE_DIR / collection
collection_dir.mkdir(parents=True, exist_ok=True)

content = await file.read()
@router.post("/import-by-path")
async def import_mesh_by_path(body: ImportByPathRequest):
file_path = Path(body.path)
if not file_path.is_file():
raise HTTPException(400, "File not found")

ext = file_path.suffix.lstrip(".").lower()
if ext not in ("glb", "obj", "stl", "ply"):
raise HTTPException(400, f"Unsupported format: {ext}")

if ext == "glb":
output_path = collection_dir / "mesh.glb"
output_path.write_bytes(content)
else:
tmp_input = collection_dir / f"input.{ext}"
tmp_input.write_bytes(content)
try:
loaded = trimesh.load(str(tmp_input))
output_path = collection_dir / "mesh.glb"
loaded.export(str(output_path))
finally:
tmp_input.unlink(missing_ok=True)

return {"url": f"/workspace/{collection}/mesh.glb"}
# Serve the original file directly — no copy
return {"url": f"/optimize/serve-file?path={quote(str(file_path))}"}

# Non-GLB: convert to GLB in a temp directory (not the workspace)
tmp_dir = tempfile.mkdtemp(prefix="modly_import_")
output_path = os.path.join(tmp_dir, "mesh.glb")
loaded = trimesh.load(str(file_path))
loaded.export(output_path)
return {"url": f"/optimize/serve-file?path={quote(output_path)}"}


@router.get("/serve-file")
def serve_file(path: str):
file_path = Path(path)
if not file_path.is_file():
raise HTTPException(404, "File not found")
if file_path.suffix.lower() != ".glb":
raise HTTPException(400, "Only GLB files can be served")
return FileResponse(str(file_path), media_type="model/gltf-binary")


@router.get("/export")
Expand Down
76 changes: 59 additions & 17 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,39 +22,58 @@ type WindowGetter = () => BrowserWindow | null

// ─── GPU detect (best-effort, no Python required) ─────────────────────────────

function detectGpuSm(): Promise<number> {
interface GpuInfo { sm: number; cudaVersion: number }

function detectGpuInfo(): Promise<GpuInfo> {
return new Promise((resolve) => {
const proc = spawn('nvidia-smi', ['--query-gpu=compute_cap', '--format=csv,noheader'], {
// Query compute cap + driver version in one call
const proc = spawn('nvidia-smi', ['--query-gpu=compute_cap,driver_version', '--format=csv,noheader'], {
stdio: ['ignore', 'pipe', 'ignore'],
})
let out = ''
proc.stdout?.on('data', (d: Buffer) => { out += d.toString() })
proc.on('close', (code) => {
if (code === 0) {
const cap = out.trim().split('\n')[0].trim() // e.g. "8.6"
const sm = Math.round(parseFloat(cap) * 10) // → 86
resolve(isNaN(sm) ? 86 : sm)
const line = out.trim().split('\n')[0].trim() // e.g. "8.6, 551.61"
const parts = line.split(',').map(s => s.trim())
const sm = Math.round(parseFloat(parts[0] ?? '') * 10) // → 86
// Derive max supported CUDA version from driver version
// Driver ≥ 520 → CUDA 11.8, ≥ 525 → 12.0, ≥ 530 → 12.1, ≥ 535 → 12.2,
// ≥ 545 → 12.3, ≥ 550 → 12.4, ≥ 555 → 12.5, ≥ 560 → 12.6
const driverMajor = parseInt((parts[1] ?? '').split('.')[0] ?? '0', 10)
let cudaVersion = 118 // safe minimum
if (driverMajor >= 570) cudaVersion = 128 // Blackwell (RTX 50xx, sm_120)
else if (driverMajor >= 560) cudaVersion = 126
else if (driverMajor >= 555) cudaVersion = 125
else if (driverMajor >= 550) cudaVersion = 124
else if (driverMajor >= 545) cudaVersion = 123
else if (driverMajor >= 535) cudaVersion = 122
else if (driverMajor >= 530) cudaVersion = 121
else if (driverMajor >= 525) cudaVersion = 120
else if (driverMajor >= 520) cudaVersion = 118
resolve({ sm: isNaN(sm) ? 86 : sm, cudaVersion })
} else {
resolve(86) // no GPU or nvidia-smi missing — assume Ampere as safe default
resolve({ sm: 86, cudaVersion: 118 })
}
})
proc.on('error', () => resolve(86))
proc.on('error', () => resolve({ sm: 86, cudaVersion: 118 }))
})
}

// ─── Run an extension's setup.py directly (no FastAPI needed) ─────────────────

function runExtensionSetup(
extDir: string,
gpuSm: number,
onLog?: (line: string) => void,
extDir: string,
gpuSm: number,
cudaVersion: number,
onLog?: (line: string) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const userData = app.getPath('userData')
const pythonExe = getVenvPythonExe(userData)
const setupPy = join(extDir, 'setup.py')

const args = JSON.stringify({ python_exe: pythonExe, ext_dir: extDir, gpu_sm: gpuSm })
const args = JSON.stringify({ python_exe: pythonExe, ext_dir: extDir, gpu_sm: gpuSm, cuda_version: cudaVersion })
const proc = spawn(pythonExe, [setupPy, args], {
stdio: ['ignore', 'pipe', 'pipe'],
})
Expand Down Expand Up @@ -256,6 +275,14 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
return buffer.toString('base64')
})

ipcMain.handle('fs:readScreenshotDataUrl', async (_, filename: string) => {
const filePath = app.isPackaged
? join(process.resourcesPath, 'screenshots', filename)
: join(app.getAppPath(), 'src/assets', filename)
const buffer = await readFile(filePath)
return `data:image/png;base64,${buffer.toString('base64')}`
})

// Model management
ipcMain.handle('model:listDownloaded', () => {
const modelsDir = getSettings(app.getPath('userData')).modelsDir
Expand Down Expand Up @@ -647,24 +674,39 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
if (isProcess) {
// 6a. Process extension: npm install if package.json present
if (existsSync(join(destDir, 'package.json'))) {
emit({ step: 'setting_up' })
emit({ step: 'setting_up', message: 'Installing dependencies…' })
await new Promise<void>((resolve, reject) => {
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const child = spawn(npm, ['install', '--omit=dev', '--no-audit', '--no-fund'], {
cwd: destDir,
stdio: 'pipe',
})
let buf = ''
const onData = (chunk: Buffer) => {
buf += chunk.toString()
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const raw of lines) {
const line = raw.replace(/\x1b\[[0-9;]*m/g, '').trim()
if (line) emit({ step: 'setting_up', message: line })
}
}
child.stdout?.on('data', onData)
child.stderr?.on('data', onData)
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`npm install failed (exit ${code})`)))
child.on('error', reject)
})
}
} else {
// 6b. Model extension: run setup.py directly (no FastAPI required)
if (existsSync(join(destDir, 'setup.py'))) {
emit({ step: 'setting_up' })
const gpuSm = await detectGpuSm()
emit({ step: 'setting_up', message: 'Setting up Python environment…' })
const { sm: gpuSm, cudaVersion } = await detectGpuInfo()
try {
await runExtensionSetup(destDir, gpuSm, (line) => logger.info(`[ext-setup] ${line}`))
await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => {
logger.info(`[ext-setup] ${line}`)
emit({ step: 'setting_up', message: line })
})
} catch (setupErr: any) {
throw new Error(`Extension setup failed: ${setupErr?.message ?? setupErr}`)
}
Expand Down Expand Up @@ -723,8 +765,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe
if (!existsSync(join(extDir, 'setup.py'))) {
return { success: false, error: 'No setup.py found for this extension' }
}
const gpuSm = await detectGpuSm()
await runExtensionSetup(extDir, gpuSm, (line) => logger.info(`[ext-repair] ${line}`))
const { sm: gpuSm, cudaVersion } = await detectGpuInfo()
await runExtensionSetup(extDir, gpuSm, cudaVersion, (line) => logger.info(`[ext-repair] ${line}`))
try {
await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 })
} catch { /* ignore if Python is not running yet */ }
Expand Down
2 changes: 2 additions & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ contextBridge.exposeInMainWorld('electron', {
ipcRenderer.invoke('fs:moveDirectory', args),
deleteDirectory: (dirPath: string): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke('fs:deleteDirectory', dirPath),
readScreenshotDataUrl: (filename: string): Promise<string> =>
ipcRenderer.invoke('fs:readScreenshotDataUrl', filename),
},

// Settings
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modly",
"version": "0.2.1",
"version": "0.3.0",
"description": "Local AI-powered 3D mesh generation from images",
"main": "./out/main/index.js",
"author": "Modly",
Expand Down
Binary file added resources/screenshots/helper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
21 changes: 5 additions & 16 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ export default function GeneratePage(): JSX.Element {
const [smoothing, setSmoothing] = useState(false)
const [importing, setImporting] = useState(false)
const dragging = useRef(false)
const importInputRef = useRef<HTMLInputElement>(null)

const isGenerating = useAppStore((s) =>
s.currentJob?.status === 'uploading' || s.currentJob?.status === 'generating'
Expand Down Expand Up @@ -245,14 +244,13 @@ export default function GeneratePage(): JSX.Element {
link.click()
}

async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
async function handleImportMesh() {
const filePath = await window.electron.fs.selectMeshFile()
if (!filePath) return
setOpenPanel(null)
setImporting(true)
try {
const { url } = await importMesh(file)
const { url } = await importMesh(filePath)
const job: GenerationJob = {
id: `import-${Date.now()}`,
imageFile: '',
Expand Down Expand Up @@ -327,15 +325,6 @@ export default function GeneratePage(): JSX.Element {
/>

<div className="flex-1 flex flex-col overflow-hidden">
{/* Hidden file input for mesh import */}
<input
ref={importInputRef}
type="file"
accept=".glb,.obj,.stl,.ply"
className="hidden"
onChange={handleImportFile}
/>

{/* Header bar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 bg-surface-400 shrink-0">

Expand Down Expand Up @@ -397,7 +386,7 @@ export default function GeneratePage(): JSX.Element {
{openPanel === 'import' && (
<div className="absolute top-full left-0 mt-1 z-50 bg-zinc-900 border border-zinc-700/60 rounded-xl p-1 flex flex-col gap-0.5 min-w-[140px] shadow-xl">
<button
onClick={() => importInputRef.current?.click()}
onClick={handleImportMesh}
className="px-3 py-2 text-left hover:bg-zinc-800 rounded-lg transition-colors flex items-center gap-2.5"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" className="text-zinc-400">
Expand Down
18 changes: 18 additions & 0 deletions src/areas/models/ModelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,24 @@ export default function ModelsPage(): JSX.Element {
</div>
)}

{isInstalling && installProgress?.step === 'setting_up' && (
<div className="flex flex-col gap-2 px-3 py-2.5 rounded-lg bg-zinc-800/60 border border-zinc-700/40">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="w-3 h-3 rounded-full border-2 border-accent/40 border-t-accent animate-spin shrink-0" />
<span className="text-[10px] text-zinc-400 truncate">
{installProgress.message ?? 'Setting up environment…'}
</span>
</div>
<span className="text-[9px] text-zinc-600 shrink-0">May take a few minutes</span>
</div>
{/* Indeterminate progress bar */}
<div className="h-0.5 rounded-full bg-zinc-700 overflow-hidden">
<div className="h-full w-1/3 rounded-full bg-accent animate-[slide_1.5s_ease-in-out_infinite]" />
</div>
</div>
)}

{installProgress?.step === 'done' && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-emerald-950/30 border border-emerald-800/30">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-emerald-400 shrink-0">
Expand Down
Loading