From 64a4330fd66e36336bbc9b5a92e74ab70ed6e862 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sun, 10 May 2026 14:09:38 +0200 Subject: [PATCH 01/15] fix(extension): manifest extensions error handle --- electron/main/python-bridge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/electron/main/python-bridge.ts b/electron/main/python-bridge.ts index 4e8d436..f0451ce 100644 --- a/electron/main/python-bridge.ts +++ b/electron/main/python-bridge.ts @@ -56,7 +56,7 @@ export class PythonBridge { MODELS_DIR: this.resolveModelsDir(), WORKSPACE_DIR: this.resolveWorkspaceDir(), EXTENSIONS_DIR: this.resolveExtensionsDir(), - SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] ?? '', + ...(process.env['SELECTED_MODEL_ID'] ? { SELECTED_MODEL_ID: process.env['SELECTED_MODEL_ID'] } : {}), HUGGING_FACE_HUB_TOKEN: this.resolveHfToken(), HF_TOKEN: this.resolveHfToken(), } From af7c4a8f4eadbc9126d4081bdd3d80b95869bd2e Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sun, 10 May 2026 16:50:03 +0200 Subject: [PATCH 02/15] fix(architecture): terminate stale process runners on extension reinstall and reload --- electron/main/ipc-handlers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 24cb9df..be5b3c3 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -701,6 +701,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const destDir = join(extensionsDir, manifest.id) if (existsSync(destDir)) { + terminateProcessRunner(manifest.id) await rmAsync(destDir, { recursive: true, force: true }) } await cp(extractDir, destDir, { recursive: true }) @@ -843,6 +844,7 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Trigger Python extension reload (without touching the filesystem) ipcMain.handle('extensions:reload', async () => { + terminateAllProcessRunners() try { const res = await axios.post(`${API_BASE_URL}/extensions/reload`, {}, { timeout: 10_000 }) return { success: true, errors: (res.data as { errors?: Record }).errors ?? {} } From 9b93b453bc3f6ce48af57bf8e616fa4cd0014877 Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Sun, 10 May 2026 16:42:31 +0200 Subject: [PATCH 03/15] fix(architecture): purge stale generation jobs to prevent memory leak --- api/routers/generation.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api/routers/generation.py b/api/routers/generation.py index 2c4eb18..bdd0492 100644 --- a/api/routers/generation.py +++ b/api/routers/generation.py @@ -1,6 +1,7 @@ import asyncio import json import threading +import time import traceback import uuid from typing import Dict @@ -16,6 +17,19 @@ _jobs: Dict[str, JobStatus] = {} _cancelled: set = set() _cancel_events: Dict[str, threading.Event] = {} +_completed_at: Dict[str, float] = {} + +_JOB_TTL = 1800 # purge terminal jobs after 30 minutes + + +def _purge_old_jobs() -> None: + cutoff = time.monotonic() - _JOB_TTL + stale = [jid for jid, t in _completed_at.items() if t < cutoff] + for jid in stale: + _jobs.pop(jid, None) + _cancelled.discard(jid) + _cancel_events.pop(jid, None) + _completed_at.pop(jid, None) @router.post("/from-image") @@ -63,6 +77,8 @@ async def generate_from_image( **model_params, } + _purge_old_jobs() + job = JobStatus(job_id=job_id, status="pending", progress=0) _jobs[job_id] = job _cancel_events[job_id] = threading.Event() @@ -91,6 +107,7 @@ async def cancel_job(job_id: str): _cancel_events[job_id].set() if job.status in ("pending", "running"): job.status = "cancelled" + _completed_at[job_id] = time.monotonic() # Kill the active generator subprocess immediately so inference stops now. # _run_generation will catch the resulting exception, see job_id in _cancelled, # and return cleanly without setting an error status. @@ -162,6 +179,7 @@ def progress_cb(pct: int, step: str = "") -> None: job.status = "done" job.progress = 100 + _completed_at[job_id] = time.monotonic() try: rel = output_path.relative_to(WORKSPACE_DIR) job.output_url = f"/workspace/{rel.as_posix()}" @@ -170,6 +188,7 @@ def progress_cb(pct: int, step: str = "") -> None: except GenerationCancelled: job.status = "cancelled" + _completed_at[job_id] = time.monotonic() except Exception as exc: if job_id in _cancelled: return @@ -177,3 +196,4 @@ def progress_cb(pct: int, step: str = "") -> None: print(f"[Generation ERROR] {exc}\n{tb}") job.status = "error" job.error = tb.strip() + _completed_at[job_id] = time.monotonic() From a22c29535e607949f17962bc2b5534512f545475 Mon Sep 17 00:00:00 2001 From: Lorchie Date: Sun, 10 May 2026 17:29:45 +0200 Subject: [PATCH 04/15] Add Wait node and conditional param visibility --- .../generate/components/WorkflowPanel.tsx | 38 +++++++++++- src/areas/workflows/WorkflowsPage.tsx | 7 ++- src/areas/workflows/nodes/ExtensionNode.tsx | 12 +++- src/areas/workflows/nodes/WaitNode.tsx | 52 ++++++++++++++++ src/areas/workflows/workflowRunStore.ts | 59 +++++++++++++++++-- src/shared/types/electron.d.ts | 1 + 6 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 src/areas/workflows/nodes/WaitNode.tsx diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 0f23770..a6a98b2 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -330,6 +330,39 @@ function TextParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: FlowN ) } +function WaitParamRow({ nodeId }: { nodeId: string }) { + const status = useWorkflowRunStore((s) => s.runState.status) + const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId) + const continueRun = useWorkflowRunStore((s) => s.continueRun) + const isPaused = status === 'paused' && activeNodeId === nodeId + + return ( +
+
+ + + + Wait +
+ {isPaused ? ( + + ) : ( +

+ Pauses the workflow until you click Continue. +

+ )} +
+ ) +} + function ExtensionParamRow({ nodeId, ext, nodes, onPatch }: { nodeId: string; ext: WorkflowExtension; nodes: FlowNode[]; onPatch: PatchFn }) { const [expanded, setExpanded] = useState(true) const node = nodes.find((n) => n.id === nodeId) @@ -411,7 +444,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { const { setCurrentJob } = useAppStore() const { runState, run, cancel } = useWorkflowRunStore() - const isRunning = runState.status === 'running' + const isRunning = runState.status === 'running' || runState.status === 'paused' // Update AddToScene node when run completes useEffect(() => { @@ -455,7 +488,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { ) const paramNodes = sortedNodes.filter((n) => - (n.type === 'imageNode' || n.type === 'textNode' || n.type === 'meshNode' || n.type === 'extensionNode') + (n.type === 'imageNode' || n.type === 'textNode' || n.type === 'meshNode' || n.type === 'extensionNode' || n.type === 'waitNode') && (n.data as { showInGenerate?: boolean }).showInGenerate === true, ) @@ -476,6 +509,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: { {node.type === 'imageNode' && } {node.type === 'textNode' && } {node.type === 'meshNode' && } + {node.type === 'waitNode' && } {node.type === 'extensionNode' && (() => { const ext = getWorkflowExtension(node.data.extensionId ?? '', allExtensions) return ext ? : null diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index e2575e6..05641f1 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -27,13 +27,14 @@ import TextNode from './nodes/TextNode' import AddToSceneNode from './nodes/AddToSceneNode' import Load3DMeshNode from './nodes/Load3DMeshNode' import PreviewImageNode from './nodes/PreviewImageNode' +import WaitNode from './nodes/WaitNode' import WorkflowEdge from './nodes/WorkflowEdge' // ─── Constants ──────────────────────────────────────────────────────────────── const DRAG_KEY = 'modly/extension-id' const DRAG_NODE_KEY = 'modly/node-type' -const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode } +const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode } const EDGE_TYPES = { workflowEdge: WorkflowEdge } const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } @@ -155,6 +156,7 @@ const PANEL_BUILTIN_NODES = [ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <> }, { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <> }, { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <> }, + { type: 'waitNode', label: 'Wait', color: '#71717a', icon: <> }, ] function ExtGroupHeader({ title, author, expanded, onToggle, count }: { title: string; author?: string; expanded: boolean; onToggle: () => void; count: number }) { @@ -398,6 +400,7 @@ const BUILTIN_NODES = [ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' }, { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' }, + { type: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' }, ] type PaletteItem = @@ -778,7 +781,7 @@ function WorkflowCanvasInner({ }) { const { screenToFlowPosition, updateNodeData, getNode } = useReactFlow() const { runState, run: runWorkflow, cancel } = useWorkflowRunStore() - const isRunning = runState.status === 'running' + const isRunning = runState.status === 'running' || runState.status === 'paused' const [nodes, setNodes, onNodesChange] = useNodesState(workflow.nodes as Node[]) const [edges, setEdges, onEdgesChange] = useEdgesState(workflow.edges as Edge[]) diff --git a/src/areas/workflows/nodes/ExtensionNode.tsx b/src/areas/workflows/nodes/ExtensionNode.tsx index d0498ed..692a467 100644 --- a/src/areas/workflows/nodes/ExtensionNode.tsx +++ b/src/areas/workflows/nodes/ExtensionNode.tsx @@ -152,6 +152,16 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data updateNodeData(id, { params: { ...data.params, [key]: val } }) }, [id, data.params, updateNodeData]) + const paramById = new Map(ext?.params.map((p) => [p.id, p])) + + const isVisible = (param: ParamSchema): boolean => { + if (!param.show_if) return true + return Object.entries(param.show_if).every(([key, expected]) => { + const current = data.params[key] ?? paramById.get(key)?.default + return Array.isArray(expected) ? expected.includes(current as string | number) : current === expected + }) + } + // ── IO subheader ───────────────────────────────────────────────────────── const ioSubheader = isMulti ? ( // Multi-input layout: one row per input, output on first row @@ -242,7 +252,7 @@ export default function ExtensionNode({ id, data, selected }: { id: string; data > {hasParams && (
- {ext!.params.map((param) => { + {ext!.params.filter(isVisible).map((param) => { const val = (data.params[param.id] ?? param.default) as number | string return (
diff --git a/src/areas/workflows/nodes/WaitNode.tsx b/src/areas/workflows/nodes/WaitNode.tsx new file mode 100644 index 0000000..8e30302 --- /dev/null +++ b/src/areas/workflows/nodes/WaitNode.tsx @@ -0,0 +1,52 @@ +import { Handle, Position } from '@xyflow/react' +import type { WFNodeData } from '@shared/types/electron.d' +import { useWorkflowRunStore } from '../workflowRunStore' +import BaseNode from './BaseNode' + +const HANDLE_STYLE = { background: '#71717a', width: 14, height: 14, border: '2.5px solid #18181b' } + +export default function WaitNode({ id, data, selected }: { id: string; data: WFNodeData; selected?: boolean }) { + const status = useWorkflowRunStore((s) => s.runState.status) + const activeNodeId = useWorkflowRunStore((s) => s.activeNodeId) + const continueRun = useWorkflowRunStore((s) => s.continueRun) + const isPaused = status === 'paused' && activeNodeId === id + + return ( + + + + + } + subheader={isPaused ? ( + + ) : undefined} + handles={ + <> + + + + } + > +
+

+ {isPaused ? 'Workflow paused — click Continue to resume.' : 'Pauses the workflow until you click Continue.'} +

+
+
+ ) +} diff --git a/src/areas/workflows/workflowRunStore.ts b/src/areas/workflows/workflowRunStore.ts index 44b8c3e..5b86668 100644 --- a/src/areas/workflows/workflowRunStore.ts +++ b/src/areas/workflows/workflowRunStore.ts @@ -8,7 +8,7 @@ import type { Workflow, WFNode, WFEdge } from '@shared/types/electron.d' // ─── Types ──────────────────────────────────────────────────────────────────── export interface WorkflowRunState { - status: 'idle' | 'running' | 'done' | 'error' + status: 'idle' | 'running' | 'paused' | 'done' | 'error' blockIndex: number blockTotal: number blockProgress: number @@ -25,6 +25,14 @@ const IDLE: WorkflowRunState = { // Module-level refs — survive component unmounts / navigation const _cancel = { current: false } const _activeJobId = { current: null as string | null } +const _resume = { current: null as (() => void) | null } + +function flushResume(): void { + const fn = _resume.current + if (!fn) return + _resume.current = null + fn() +} // ─── Topological sort ───────────────────────────────────────────────────────── @@ -60,9 +68,10 @@ interface WorkflowRunStore { /** nodeId → workspace URL for image outputs (populated after each run) */ nodeImageOutputs: Record - run: (workflow: Workflow, allExtensions: WorkflowExtension[]) => Promise - cancel: () => void - reset: () => void + run: (workflow: Workflow, allExtensions: WorkflowExtension[], overrideImageData?: string) => Promise + cancel: () => void + reset: () => void + continueRun: () => void } export const useWorkflowRunStore = create((set) => ({ @@ -77,7 +86,9 @@ export const useWorkflowRunStore = create((set) => ({ const appState = useAppStore.getState() const apiUrl = appState.apiUrl const ordered = topoSort(workflow.nodes, workflow.edges) - const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled) + const execNodes = ordered.filter((n) => + (n.type === 'extensionNode' || n.type === 'waitNode') && n.data.enabled, + ) const selectedImagePath = appState.selectedImagePath ?? '' const selectedImageData = appState.selectedImageData ?? undefined @@ -108,6 +119,7 @@ export const useWorkflowRunStore = create((set) => ({ // nodeId → { filePath, text, outputType } const nodeOutputs = new Map() + const outputNodeIds = new Set(ordered.filter((n) => n.type === 'outputNode').map((n) => n.id)) // Pre-populate source nodes for (const node of ordered) { @@ -182,6 +194,21 @@ export const useWorkflowRunStore = create((set) => ({ runState: { ...s.runState, blockIndex: i, blockProgress: 0, blockStep: 'Starting…' }, })) + // ── Wait node → pause until continueRun(), then passthrough ─────── + if (node.type === 'waitNode') { + set((s) => ({ runState: { ...s.runState, status: 'paused', blockStep: 'Paused — click Continue' } })) + await new Promise((resolve) => { _resume.current = resolve }) + if (_cancel.current) { set({ runState: IDLE, activeNodeId: null }); return } + + nodeOutputs.set(node.id, { + filePath: nodeInputPath, + text: nodeInputText, + outputType: incomingEdges[0] ? nodeOutputs.get(incomingEdges[0].source)?.outputType : undefined, + }) + set((s) => ({ runState: { ...s.runState, status: 'running' } })) + continue + } + // ── Model extensions → HTTP API ─────────────────────────────────── // Process extensions → IPC runProcess const isModelNode = ext?.type === 'model' @@ -272,6 +299,20 @@ export const useWorkflowRunStore = create((set) => ({ // Store output with type for downstream routing const outputType = ext?.output ?? (nodeInputPath ? 'mesh' : undefined) nodeOutputs.set(node.id, { filePath: nodeInputPath, text: nodeInputText, outputType }) + + // If this node feeds an Add-to-Scene, push the mesh to currentJob + // immediately so the 3D viewer loads it without waiting for the rest of the run. + const norm = nodeInputPath?.replace(/\\/g, '/') + if ( + norm?.startsWith(workspaceDir) && + workflow.edges.some((e) => e.source === node.id && outputNodeIds.has(e.target)) + ) { + useAppStore.getState().updateCurrentJob({ + status: 'done', + progress: 100, + outputUrl: `/workspace/${norm.slice(workspaceDir.length).replace(/^\//, '')}`, + }) + } } // ── Collect image outputs for preview nodes ─────────────────────── @@ -289,7 +330,8 @@ export const useWorkflowRunStore = create((set) => ({ let outputUrl: string | undefined let outputPath: string | undefined - const outputNodeDef = ordered.find((n) => n.type === 'outputNode') + // Use the last AddToScene in topo order — its predecessor is the final scene mesh. + const outputNodeDef = [...ordered].reverse().find((n) => n.type === 'outputNode') if (outputNodeDef) { for (const edge of workflow.edges.filter((e) => e.target === outputNodeDef.id)) { const src = nodeOutputs.get(edge.source) @@ -340,6 +382,7 @@ export const useWorkflowRunStore = create((set) => ({ cancel() { _cancel.current = true + flushResume() if (_activeJobId.current) { const apiUrl = useAppStore.getState().apiUrl axios.create({ baseURL: apiUrl }).post(`/generate/cancel/${_activeJobId.current}`).catch(() => {}) @@ -351,4 +394,8 @@ export const useWorkflowRunStore = create((set) => ({ reset() { set({ runState: IDLE, activeNodeId: null, activeWorkflowId: null, nodeImageOutputs: {} }) }, + + continueRun() { + flushResume() + }, })) diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index acb21ad..f5d5ca8 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -38,6 +38,7 @@ export interface ParamSchema { max?: number step?: number tooltip?: string + show_if?: Record } export interface ProcessExtension { From dde0bbb64468feaf3b6d498e14c714f9de1fcdcf Mon Sep 17 00:00:00 2001 From: DrHepa Date: Sun, 10 May 2026 23:35:56 +0200 Subject: [PATCH 05/15] fix(extensions): harden extension path handling --- electron/main/extension-path-guard.test.ts | 52 +++++++++++++++++++++ electron/main/extension-path-guard.ts | 53 ++++++++++++++++++++++ electron/main/ipc-handlers.ts | 25 ++++++---- 3 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 electron/main/extension-path-guard.test.ts create mode 100644 electron/main/extension-path-guard.ts diff --git a/electron/main/extension-path-guard.test.ts b/electron/main/extension-path-guard.test.ts new file mode 100644 index 0000000..91d9170 --- /dev/null +++ b/electron/main/extension-path-guard.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' + +async function loadGuard() { + return import(new URL('./extension-path-guard.ts', import.meta.url).href) +} + +test('assertSafeExtensionId accepts conservative extension ids', async () => { + const { assertSafeExtensionId } = await loadGuard() + assert.equal(assertSafeExtensionId('mesh-process'), 'mesh-process') + assert.equal(assertSafeExtensionId('image_model.v2'), 'image_model.v2') + assert.equal(assertSafeExtensionId('kimodo-soma-rp'), 'kimodo-soma-rp') +}) + +test('assertSafeExtensionId rejects empty, traversal and absolute ids', async () => { + const { assertSafeExtensionId } = await loadGuard() + assert.throws(() => assertSafeExtensionId(''), /must not be empty/i) + assert.throws(() => assertSafeExtensionId('.'), /is invalid/i) + assert.throws(() => assertSafeExtensionId('..'), /is invalid/i) + assert.throws(() => assertSafeExtensionId('../escape'), /path separators/i) + assert.throws(() => assertSafeExtensionId('..\\escape'), /path separators/i) + assert.throws(() => assertSafeExtensionId('/abs/path'), /absolute path|path separators/i) +}) + +test('assertSafeExtensionId rejects uppercase and unsupported characters', async () => { + const { assertSafeExtensionId } = await loadGuard() + assert.throws(() => assertSafeExtensionId('Mesh-Process'), /must match/i) + assert.throws(() => assertSafeExtensionId('bad id'), /must match/i) + assert.throws(() => assertSafeExtensionId('bad:id'), /must match/i) +}) + +test('resolveExtensionPathWithinRoot confines paths to root', async () => { + const { resolveExtensionPathWithinRoot } = await loadGuard() + const root = path.join('/tmp', 'extensions-root') + assert.equal(resolveExtensionPathWithinRoot(root, 'mesh-process'), path.resolve(root, 'mesh-process')) + assert.throws(() => resolveExtensionPathWithinRoot(root, '../escape'), /path separators/i) +}) + +test('resolvePathWithinRoot rejects canonical escapes', async () => { + const { resolvePathWithinRoot } = await loadGuard() + const root = path.join('/tmp', 'extensions-root') + assert.equal(resolvePathWithinRoot(root, '.modly-backup-safe-123'), path.resolve(root, '.modly-backup-safe-123')) + assert.throws(() => resolvePathWithinRoot(root, '../escape'), /escapes root/i) +}) + +test('buildExtensionBackupPath stays within root and rejects unsafe ids', async () => { + const { buildExtensionBackupPath } = await loadGuard() + const root = path.join('/tmp', 'extensions-root') + assert.equal(buildExtensionBackupPath(root, 'mesh-process', '123'), path.resolve(root, '.modly-backup-mesh-process-123')) + assert.throws(() => buildExtensionBackupPath(root, '../escape', '123'), /path separators/i) +}) diff --git a/electron/main/extension-path-guard.ts b/electron/main/extension-path-guard.ts new file mode 100644 index 0000000..717dbf3 --- /dev/null +++ b/electron/main/extension-path-guard.ts @@ -0,0 +1,53 @@ +import { isAbsolute, relative, resolve as resolvePath } from 'node:path' + +const EXTENSION_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/ + +export function assertSafeExtensionId(extensionId: unknown): string { + if (typeof extensionId !== 'string') { + throw new Error('Extension id must be a string') + } + + const trimmed = extensionId.trim() + if (!trimmed) { + throw new Error('Extension id must not be empty') + } + + if (trimmed === '.' || trimmed === '..') { + throw new Error(`Extension id "${extensionId}" is invalid`) + } + + if (isAbsolute(trimmed)) { + throw new Error(`Extension id "${extensionId}" must not be an absolute path`) + } + + if (trimmed.includes('/') || trimmed.includes('\\')) { + throw new Error(`Extension id "${extensionId}" must not contain path separators`) + } + + if (!EXTENSION_ID_PATTERN.test(trimmed)) { + throw new Error(`Extension id "${extensionId}" must match ${EXTENSION_ID_PATTERN}`) + } + + return trimmed +} + +export function resolvePathWithinRoot(rootDir: string, unsafeLeaf: string): string { + const resolvedRoot = resolvePath(rootDir) + const resolvedCandidate = resolvePath(resolvedRoot, unsafeLeaf) + const normalizedRelative = relative(resolvedRoot, resolvedCandidate).replace(/\\/g, '/') + + if (normalizedRelative === '..' || normalizedRelative.startsWith('../') || isAbsolute(normalizedRelative)) { + throw new Error(`Resolved path escapes root: ${unsafeLeaf}`) + } + + return resolvedCandidate +} + +export function resolveExtensionPathWithinRoot(rootDir: string, extensionId: unknown): string { + return resolvePathWithinRoot(rootDir, assertSafeExtensionId(extensionId)) +} + +export function buildExtensionBackupPath(rootDir: string, extensionId: unknown, suffix: string): string { + const safeId = assertSafeExtensionId(extensionId) + return resolvePathWithinRoot(rootDir, `.modly-backup-${safeId}-${suffix}`) +} diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index be5b3c3..06579cc 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -18,6 +18,7 @@ import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' import { getBuiltinExtensionsDir } from './builtin-sync' import { spawn } from 'child_process' +import { assertSafeExtensionId, resolveExtensionPathWithinRoot } from './extension-path-guard' type WindowGetter = () => BrowserWindow | null @@ -674,6 +675,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const manifest = JSON.parse(manifestRaw) as ParsedManifest if (!manifest.id) throw new Error('manifest.json: required field "id" missing') + const extensionId = assertSafeExtensionId(manifest.id) + manifest.id = extensionId if (!manifest.nodes?.length) throw new Error('manifest.json: required field "nodes" missing or empty') const isProcess = manifest.type === 'process' @@ -698,10 +701,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // 5. Copy to extensions directory (overwrite if already present) const extensionsDir = getSettings(app.getPath('userData')).extensionsDir await mkdir(extensionsDir, { recursive: true }) - const destDir = join(extensionsDir, manifest.id) + const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId) if (existsSync(destDir)) { - terminateProcessRunner(manifest.id) + terminateProcessRunner(extensionId) await rmAsync(destDir, { recursive: true, force: true }) } await cp(extractDir, destDir, { recursive: true }) @@ -783,11 +786,11 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } catch { /* Python might not be running yet */ } } - emit({ step: 'done', extensionId: manifest.id }) + emit({ step: 'done', extensionId }) const trustedRepos = await fetchTrustedRepos() - const ext = parseExtensionManifest(manifest, manifest.id, trustedRepos) - return { success: true, extensionId: manifest.id, extension: ext } + const ext = parseExtensionManifest(manifest, extensionId, trustedRepos) + return { success: true, extensionId, extension: ext } } catch (err) { emit({ step: 'error', message: String(err) }) @@ -802,16 +805,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Uninstall an extension — built-ins cannot be uninstalled ipcMain.handle('extensions:uninstall', async (_, extensionId: string) => { const userData = app.getPath('userData') - const builtinPath = join(getBuiltinExtensionsDir(), extensionId) + const safeExtensionId = assertSafeExtensionId(extensionId) + const builtinPath = resolveExtensionPathWithinRoot(getBuiltinExtensionsDir(), safeExtensionId) if (existsSync(builtinPath)) { - return { success: false, error: `"${extensionId}" is a built-in extension and cannot be uninstalled.` } + return { success: false, error: `"${safeExtensionId}" is a built-in extension and cannot be uninstalled.` } } const extensionsDir = getSettings(userData).extensionsDir - const extPath = join(extensionsDir, extensionId) + const extPath = resolveExtensionPathWithinRoot(extensionsDir, safeExtensionId) try { // Terminate process runner if it's a process extension - terminateProcessRunner(extensionId) + terminateProcessRunner(safeExtensionId) await rmAsync(extPath, { recursive: true, force: true }) // Hot-reload Python so it stops using the deleted model extension @@ -827,7 +831,8 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe // Re-run setup.py for a model extension (creates the venv if missing) ipcMain.handle('extensions:repair', async (_, extensionId: string) => { try { - const extDir = join(getSettings(app.getPath('userData')).extensionsDir, extensionId) + const safeExtensionId = assertSafeExtensionId(extensionId) + const extDir = resolveExtensionPathWithinRoot(getSettings(app.getPath('userData')).extensionsDir, safeExtensionId) if (!existsSync(join(extDir, 'setup.py'))) { return { success: false, error: 'No setup.py found for this extension' } } From f02b4b9ecd9b626267003774fef16b2dbf77f38e Mon Sep 17 00:00:00 2001 From: DrHepa Date: Sun, 10 May 2026 23:38:48 +0200 Subject: [PATCH 06/15] fix(extensions): rollback failed GitHub installs --- electron/main/ipc-handlers.ts | 44 +++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 06579cc..757b566 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -18,7 +18,7 @@ import { logger } from './logger' import { getProcessRunner, getPythonProcessRunner, getExtPythonExe, terminateProcessRunner, terminateAllProcessRunners } from './process-runner' import { getBuiltinExtensionsDir } from './builtin-sync' import { spawn } from 'child_process' -import { assertSafeExtensionId, resolveExtensionPathWithinRoot } from './extension-path-guard' +import { assertSafeExtensionId, buildExtensionBackupPath, resolveExtensionPathWithinRoot } from './extension-path-guard' type WindowGetter = () => BrowserWindow | null @@ -702,10 +702,12 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe const extensionsDir = getSettings(app.getPath('userData')).extensionsDir await mkdir(extensionsDir, { recursive: true }) const destDir = resolveExtensionPathWithinRoot(extensionsDir, extensionId) + const backupDir = existsSync(destDir) ? buildExtensionBackupPath(extensionsDir, extensionId, String(Date.now())) : null - if (existsSync(destDir)) { + try { + if (backupDir) { terminateProcessRunner(extensionId) - await rmAsync(destDir, { recursive: true, force: true }) + await rename(destDir, backupDir) } await cp(extractDir, destDir, { recursive: true }) @@ -730,15 +732,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe if (existsSync(join(destDir, 'setup.py'))) { emit({ step: 'setting_up', message: 'Setting up Python environment…' }) const { sm: gpuSm, cudaVersion } = await detectGpuInfo() - try { - await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { - logger.info(`[ext-setup] ${line}`) - emit({ step: 'setting_up', message: line }) - }) - } catch (err) { - logger.warn(`[ext-setup] setup.py failed: ${err}`) - emit({ step: 'setting_up', message: `Warning: setup failed — ${err}` }) - } + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) } } else if (isProcess) { // 6b. JS process extension: npm install if package.json present @@ -771,14 +768,10 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe if (existsSync(join(destDir, 'setup.py'))) { emit({ step: 'setting_up', message: 'Setting up Python environment…' }) const { sm: gpuSm, cudaVersion } = await detectGpuInfo() - try { - 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}`) - } + await runExtensionSetup(destDir, gpuSm, cudaVersion, (line) => { + logger.info(`[ext-setup] ${line}`) + emit({ step: 'setting_up', message: line }) + }) } try { @@ -786,6 +779,17 @@ export function setupIpcHandlers(pythonBridge: PythonBridge, getWindow: WindowGe } catch { /* Python might not be running yet */ } } + if (backupDir) { + await rmAsync(backupDir, { recursive: true, force: true }) + } + } catch (installErr) { + await rmAsync(destDir, { recursive: true, force: true }).catch(() => {}) + if (backupDir && existsSync(backupDir)) { + await rename(backupDir, destDir) + } + throw installErr + } + emit({ step: 'done', extensionId }) const trustedRepos = await fetchTrustedRepos() From bb482e61f8e53661b9313f79db65379b2cd42aca Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Mon, 11 May 2026 09:34:43 +0200 Subject: [PATCH 07/15] dump version 0.3.6 --- api/main.py | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/main.py b/api/main.py index 586763b..1404bec 100644 --- a/api/main.py +++ b/api/main.py @@ -31,7 +31,7 @@ def filter(self, record): app = FastAPI( title="Modly API", - version="0.3.5", + version="0.3.6", lifespan=lifespan, ) diff --git a/package.json b/package.json index c81592a..c323a0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "modly", - "version": "0.3.5", + "version": "0.3.6", "description": "Local AI-powered 3D mesh generation from images", "main": "./out/main/index.js", "author": "Modly", From 54285c78504f15fb744606d30cd83da83bc6773b Mon Sep 17 00:00:00 2001 From: lightningpixel <63157773+lightningpixel@users.noreply.github.com> Date: Tue, 12 May 2026 10:40:13 +0200 Subject: [PATCH 08/15] Add social media links for Modly and Lightning Pixel Added links to Modly and Lightning Pixel on X for updates. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 50738c1..ecc740d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,11 @@ Modly supports external AI model extensions. Each extension is a GitHub reposito Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with the latest news, report bugs, and share feedback. +Follow Modly and its development on X: + +- [Modly on X]((https://x.com/modly3d)) +- [Lightning Pixel on X](https://x.com/lightningpiixel) + --- ## License From a9334df0e6337e6ce67c21df10826ce88af40a7c Mon Sep 17 00:00:00 2001 From: lightningpixel <63157773+lightningpixel@users.noreply.github.com> Date: Tue, 12 May 2026 10:40:39 +0200 Subject: [PATCH 09/15] Fix link formatting for Modly on X --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ecc740d..033b6cc 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Join the [Discord server](https://discord.gg/BvjDCvS3yr) to stay up to date with Follow Modly and its development on X: -- [Modly on X]((https://x.com/modly3d)) +- [Modly on X](https://x.com/modly3d) - [Lightning Pixel on X](https://x.com/lightningpiixel) --- From 091a808e2ab73f17a5bcdbf8d46746b3abb35c39 Mon Sep 17 00:00:00 2001 From: lightningpixel <63157773+lightningpixel@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:42:01 +0200 Subject: [PATCH 10/15] Add sponsors section to README Added a sponsors section to acknowledge early supporters. --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 033b6cc..b05a1ca 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,34 @@ Follow Modly and its development on X: --- +## Sponsors + +

+ Thanks to our early sponsors for believing in Modly and helping make local AI 3D generation more accessible. +

+ +

+ + DrHepa +
+ DrHepa +
+    + + benjapenjamin +
+ benjapenjamin +
+    + + iammojogo-sudo +
+ iammojogo-sudo +
+

+ +--- + ## License MIT License — see [LICENSE](LICENSE) for details. From dc63471b122b060baf35939200842546718fe326 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:21:19 -0400 Subject: [PATCH 11/15] Implement path traversal protection in optimize.py Added input path validation to prevent path traversal attacks. --- api/routers/optimize.py | 337 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/api/routers/optimize.py b/api/routers/optimize.py index db7152c..3a0d1a2 100644 --- a/api/routers/optimize.py +++ b/api/routers/optimize.py @@ -4,6 +4,343 @@ import tempfile import uuid +try: + import pymeshlab as _pymeshlab + _PYMESHLAB_AVAILABLE = True +except ImportError: + _pymeshlab = None + _PYMESHLAB_AVAILABLE = False + +import numpy as np +import trimesh +import trimesh.visual +from fastapi import APIRouter, HTTPException, UploadFile, File +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 + +router = APIRouter(tags=["optimize"]) + + +class OptimizeRequest(BaseModel): + path: str # format: "{collection}/{filename}" + target_faces: int + + +class SmoothRequest(BaseModel): + path: str # format: "{collection}/{filename}" + iterations: int + + +class TransformRequest(BaseModel): + path: str # format: "{collection}/{filename}" + matrix: list[list[float]] # row-major 4x4 world transform + + +def _require_pymeshlab(): + if not _PYMESHLAB_AVAILABLE: + raise HTTPException(503, "pymeshlab is unavailable on this system (DLL blocked by Windows Application Control policy)") + + +def _resolve_input_path(raw_path: str) -> Path: + candidate = Path(raw_path) + if candidate.is_absolute(): + resolved = candidate.resolve() + if not resolved.exists(): + raise HTTPException(404, f"File not found: {raw_path}") + return resolved + + resolved = (WORKSPACE_DIR / raw_path).resolve() + if not str(resolved).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not resolved.exists(): + raise HTTPException(404, f"File not found: {raw_path}") + return resolved + + +@router.post("/mesh") +def optimize_mesh(body: OptimizeRequest): + _require_pymeshlab() + target_faces = max(100, min(500_000, body.target_faces)) + + input_path = _resolve_input_path(body.path) + + tmp_dir = tempfile.mkdtemp() + try: + result = _decimate(str(input_path), target_faces, tmp_dir) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + stem = input_path.stem + output_name = f"{stem}_opt{target_faces}.glb" + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name + result.export(str(output_path)) + + face_count = len(result.faces) + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}", "face_count": face_count} + + +def _has_texture(geom: trimesh.Trimesh) -> bool: + if not isinstance(geom.visual, trimesh.visual.TextureVisuals): + return False + mat = geom.visual.material + if mat is None: + return False + # Simple material (SimpleMaterial / Material) + if getattr(mat, "image", None) is not None: + return True + # PBR material (from Trellis2 SLaT texturing and GLB imports) + if getattr(mat, "baseColorTexture", None) is not None: + return True + return False + + +def _get_texture_image(geom: trimesh.Trimesh): + """Return the base color texture image regardless of material type.""" + mat = geom.visual.material + img = getattr(mat, "image", None) + if img is not None: + return img + return getattr(mat, "baseColorTexture", None) + + +def _decimate(input_path: str, target_faces: int, tmp_dir: str) -> trimesh.Trimesh: + loaded = trimesh.load(input_path) + if isinstance(loaded, trimesh.Scene): + geoms = list(loaded.geometry.values()) + geom = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0] + else: + geom = loaded + + ms = _pymeshlab.MeshSet() + + if _has_texture(geom): + # ── Textured path: OBJ intermediate to preserve UV coordinates ────── + obj_in = os.path.join(tmp_dir, "input.obj") + mtl_in = os.path.join(tmp_dir, "input.mtl") + tex_in = os.path.join(tmp_dir, "texture.png") + obj_out = os.path.join(tmp_dir, "output.obj") + + # Save texture image under a known filename (handles PBR and simple materials) + _get_texture_image(geom).save(tex_in) + + # Export OBJ (trimesh writes UV coords + MTL) + geom.export(obj_in) + + # Patch MTL so any map_Kd points to our known texture filename + if os.path.exists(mtl_in): + mtl = open(mtl_in).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_in, "w").write(mtl) + + ms.load_new_mesh(obj_in) + ms.meshing_decimation_quadric_edge_collapse( + targetfacenum=target_faces, + preservetexcoord=True, # ← keeps UV coordinates intact + preservenormal=True, + preservetopology=True, + autoclean=True, + ) + ms.save_current_mesh(obj_out) + + # Patch output MTL too, so trimesh can find the texture on load + mtl_out = obj_out.replace(".obj", ".mtl") + if os.path.exists(mtl_out): + mtl = open(mtl_out).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_out, "w").write(mtl) + + return trimesh.load(obj_out) + + else: + # ── Geometry-only path: PLY (fast, no texture to worry about) ──────── + ply_in = os.path.join(tmp_dir, "input.ply") + ply_out = os.path.join(tmp_dir, "output.ply") + + geom.export(ply_in) + ms.load_new_mesh(ply_in) + ms.meshing_decimation_quadric_edge_collapse( + targetfacenum=target_faces, + preservenormal=True, + preservetopology=True, + autoclean=True, + ) + ms.save_current_mesh(ply_out) + return trimesh.load(ply_out, force="mesh") + + +@router.post("/smooth") +def smooth_mesh(body: SmoothRequest): + _require_pymeshlab() + iterations = max(1, min(20, body.iterations)) + + input_path = _resolve_input_path(body.path) + + tmp_dir = tempfile.mkdtemp() + try: + result = _smooth(str(input_path), iterations, tmp_dir) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + stem = input_path.stem + output_name = f"{stem}_smooth{iterations}.glb" + output_dir = input_path.parent if str(input_path).startswith(str(WORKSPACE_DIR.resolve())) else WORKSPACE_DIR / "Workflows" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / output_name + result.export(str(output_path)) + + rel = output_path.relative_to(WORKSPACE_DIR).as_posix() + return {"url": f"/workspace/{rel}"} + + +def _smooth(input_path: str, iterations: int, tmp_dir: str) -> trimesh.Trimesh: + loaded = trimesh.load(input_path) + if isinstance(loaded, trimesh.Scene): + geoms = list(loaded.geometry.values()) + geom = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0] + else: + geom = loaded + + ms = _pymeshlab.MeshSet() + + if _has_texture(geom): + obj_in = os.path.join(tmp_dir, "input.obj") + mtl_in = os.path.join(tmp_dir, "input.mtl") + tex_in = os.path.join(tmp_dir, "texture.png") + obj_out = os.path.join(tmp_dir, "output.obj") + + _get_texture_image(geom).save(tex_in) + geom.export(obj_in) + + if os.path.exists(mtl_in): + mtl = open(mtl_in).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_in, "w").write(mtl) + + ms.load_new_mesh(obj_in) + ms.apply_coord_laplacian_smoothing(stepsmoothnum=iterations) + ms.save_current_mesh(obj_out) + + mtl_out = obj_out.replace(".obj", ".mtl") + if os.path.exists(mtl_out): + mtl = open(mtl_out).read() + mtl = re.sub(r"map_Kd\s+\S+", "map_Kd texture.png", mtl) + open(mtl_out, "w").write(mtl) + + return trimesh.load(obj_out) + + else: + ply_in = os.path.join(tmp_dir, "input.ply") + ply_out = os.path.join(tmp_dir, "output.ply") + + geom.export(ply_in) + ms.load_new_mesh(ply_in) + ms.apply_coord_laplacian_smoothing(stepsmoothnum=iterations) + ms.save_current_mesh(ply_out) + return trimesh.load(ply_out, force="mesh") + + +@router.post("/transform") +def transform_mesh(body: TransformRequest): + input_path = (WORKSPACE_DIR / body.path).resolve() + if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not input_path.exists(): + raise HTTPException(404, f"File not found: {body.path}") + + matrix = np.asarray(body.matrix, dtype=float) + if matrix.shape != (4, 4): + raise HTTPException(400, "matrix must be a 4x4 array") + if not np.all(np.isfinite(matrix)): + raise HTTPException(400, "matrix contains non-finite values") + + loaded = trimesh.load(str(input_path)) + loaded.apply_transform(matrix) + + stem = input_path.stem + output_name = f"{stem}_xf.glb" + output_path = input_path.parent / output_name + loaded.export(str(output_path)) + + collection_name = body.path.split("/")[0] + return {"url": f"/workspace/{collection_name}/{output_name}"} + + +class ImportByPathRequest(BaseModel): + path: str # absolute path on disk + + +@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": + # 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") +def export_mesh(path: str, format: str): + if format not in ("obj", "stl", "ply"): + raise HTTPException(400, "Supported formats: obj, stl, ply") + + input_path = (WORKSPACE_DIR / path).resolve() + if not str(input_path).startswith(str(WORKSPACE_DIR.resolve())): + raise HTTPException(400, "Invalid path") + if not input_path.exists(): + raise HTTPException(404, f"File not found: {path}") + + loaded = trimesh.load(str(input_path)) + if isinstance(loaded, trimesh.Scene): + geoms = list(loaded.geometry.values()) + mesh = trimesh.util.concatenate(geoms) if len(geoms) > 1 else geoms[0] + else: + mesh = loaded + + data = mesh.export(file_type=format) + stem = input_path.stem + mime = "text/plain" if format == "obj" else "application/octet-stream" + # trimesh exports ply as bytes even in text mode — octet-stream is fine for all binary formats + return Response( + content=data, + media_type=mime, + headers={"Content-Disposition": f'attachment; filename="{stem}.{format}"'}, + ) +import os +import re +import shutil +import tempfile +import uuid + try: import pymeshlab as _pymeshlab _PYMESHLAB_AVAILABLE = True From a0ba16ca9552f3575e7eec70d8ac5d2cf67981ac Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:24:08 -0400 Subject: [PATCH 12/15] Add transformMesh function to useApi hook --- src/shared/hooks/useApi.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/shared/hooks/useApi.ts b/src/shared/hooks/useApi.ts index 81f0c42..ec9be32 100644 --- a/src/shared/hooks/useApi.ts +++ b/src/shared/hooks/useApi.ts @@ -111,10 +111,21 @@ export function useApi() { return { url: data.url } } - async function importMesh(filePath: string): Promise<{ url: string }> { - const { data } = await client.post<{ url: string }>('/optimize/import-by-path', { path: filePath }) - return { url: data.url } - } - - return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh } + async function importMesh(filePath: string): Promise<{ url: string }> { + const { data } = await client.post<{ url: string }>('/optimize/import-by-path', { path: filePath }) + return { url: data.url } + } + + async function transformMesh( + path: string, + matrix: number[][], + ): Promise<{ url: string }> { + const { data } = await client.post<{ url: string }>('/optimize/transform', { + path, + matrix, + }) + return { url: data.url } + } + + return { generateFromImage, pollJobStatus, cancelJob, getModelStatus, downloadModel, optimizeMesh, smoothMesh, importMesh, transformMesh } } From 0ad7b2c6a219f831651de49c41700fd144d6029e Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:24:50 -0400 Subject: [PATCH 13/15] Add GizmoMode type for transformation modes --- src/areas/generate/models.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/areas/generate/models.ts b/src/areas/generate/models.ts index c7e704a..3dd2363 100644 --- a/src/areas/generate/models.ts +++ b/src/areas/generate/models.ts @@ -1,3 +1,5 @@ export type ViewMode = 'solid' | 'wireframe' | 'normals' | 'matcap' | 'uv' +export type GizmoMode = 'translate' | 'rotate' | 'scale' + export type CatalogModel = { id: string; name: string } From c8bb9c8c5b3407b3d1819295a2eeb4a9d7810759 Mon Sep 17 00:00:00 2001 From: iammojogo-sudo Date: Sun, 7 Jun 2026 23:25:30 -0400 Subject: [PATCH 14/15] Add GizmoMode and related props to ViewerToolbar --- .../generate/components/ViewerToolbar.tsx | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/areas/generate/components/ViewerToolbar.tsx b/src/areas/generate/components/ViewerToolbar.tsx index 78d97ab..635eb03 100644 --- a/src/areas/generate/components/ViewerToolbar.tsx +++ b/src/areas/generate/components/ViewerToolbar.tsx @@ -1,12 +1,17 @@ -import type { ViewMode } from '../models' -export type { ViewMode } +import type { ViewMode, GizmoMode } from '../models' +export type { ViewMode, GizmoMode } interface ViewerToolbarProps { viewMode: ViewMode autoRotate: boolean + gizmoMode: GizmoMode | null + gizmoBusy: boolean onViewMode: (mode: ViewMode) => void onAutoRotate: () => void onScreenshot: () => void + onGizmoMode: (mode: GizmoMode) => void + onApplyTransform: () => void + onResetTransform: () => void } const MODES: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [ @@ -66,12 +71,52 @@ const MODES: { mode: ViewMode; icon: React.ReactNode; label: string }[] = [ }, ] + +const GIZMOS: { mode: GizmoMode; key: string; label: string; icon: React.ReactNode }[] = [ + { + mode: 'translate', + key: 'W', + label: 'Move (W)', + icon: ( + + + + ), + }, + { + mode: 'rotate', + key: 'E', + label: 'Rotate (E)', + icon: ( + + + + + ), + }, + { + mode: 'scale', + key: 'R', + label: 'Scale (R)', + icon: ( + + + + ), + }, +] + export function ViewerToolbar({ viewMode, autoRotate, + gizmoMode, + gizmoBusy, onViewMode, onAutoRotate, onScreenshot, + onGizmoMode, + onApplyTransform, + onResetTransform, }: ViewerToolbarProps): JSX.Element { return (
@@ -84,8 +129,37 @@ export function ViewerToolbar({ > {icon} +))} + +
+ + {GIZMOS.map(({ mode, icon, label }) => ( + onGizmoMode(mode)} + > + {icon} + ))} + {gizmoMode && ( + <> + + + + + + + + + + + + + )} +
void children: React.ReactNode + disabled?: boolean } -function ToolbarButton({ active, label, onClick, children }: ToolbarButtonProps): JSX.Element { +function ToolbarButton({ active, label, onClick, children, disabled = false }: ToolbarButtonProps): JSX.Element { return (