From 55586cf01d7fb414940b38368c04c487a61e3301 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:26:53 +0200 Subject: [PATCH 01/11] integrate(#242): play GLB animation clips in viewer Adapted from OmarB97/modly (upstream PR #242). --- src/areas/generate/components/Viewer3D.tsx | 46 ++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index a1edc2d4..0d43d5c2 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -2,7 +2,7 @@ import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState import type { ReactNode, ErrorInfo, MutableRefObject } from 'react' import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber' -import { Environment, GizmoHelper, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' +import { Environment, GizmoHelper, Html, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' import { EffectComposer, Outline, Select, Selection } from '@react-three/postprocessing' import * as THREE from 'three' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' @@ -281,8 +281,48 @@ function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject } function GltfMeshModel(props: MeshModelProps): JSX.Element { - const { scene } = useGLTF(props.url) - return + const gltf = useGLTF(props.url) + const { scene, animations } = gltf + const mixerRef = useRef(null) + const [clipIndex, setClipIndex] = useState(0) + + // Rigged models arrive with one or more clips; without a mixer they render + // frozen in bind pose, which reads as "the rig failed". + useEffect(() => { + if (!animations?.length) { + mixerRef.current = null + return + } + const mixer = new THREE.AnimationMixer(scene) + mixer.clipAction(animations[Math.min(clipIndex, animations.length - 1)]).play() + mixerRef.current = mixer + return () => { + mixer.stopAllAction() + mixer.uncacheRoot(scene) + mixerRef.current = null + } + }, [scene, animations, clipIndex]) + + useFrame((_state, delta) => mixerRef.current?.update(delta)) + + return ( + <> + + {animations && animations.length > 1 && ( + + + + )} + + ) } function ObjMeshModel(props: MeshModelProps): JSX.Element { From dcfaec81df2b9e4b8577905afe2923100193a2b5 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:27:22 +0200 Subject: [PATCH 02/11] integrate(#244): normalize manifest input slots before preflight Adapted from OmarB97/modly PR #244 with tests merged into the current test file. --- src/areas/workflows/mockExtensions.test.mjs | 31 +++++++++++++++++++++ src/areas/workflows/mockExtensions.ts | 12 ++++++-- src/areas/workflows/preflight.ts | 3 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/areas/workflows/mockExtensions.test.mjs b/src/areas/workflows/mockExtensions.test.mjs index a3edbe84..0230418f 100644 --- a/src/areas/workflows/mockExtensions.test.mjs +++ b/src/areas/workflows/mockExtensions.test.mjs @@ -60,3 +60,34 @@ test('workflow discovery excludes corrupted extensions with preserved nodes', () assert.deepEqual(result.map((entry) => entry.id), ['healthy-model/generate']) }) + +test('object-shaped manifest inputs are normalized to plain type strings', () => { + const { buildAllWorkflowExtensions } = loadModule() + const ext = extension('process', { + nodes: [{ + id: 'image-to-image', name: 'Image to Image', input: 'image', + inputs: [ + { name: 'front', label: 'Front', type: 'image', required: true }, + { name: 'back', label: 'Back', type: 'image', required: false }, + ], + output: 'image', paramsSchema: [], + }], + }) + assert.deepEqual(buildAllWorkflowExtensions([], [ext])[0].inputs, ['image', 'image']) +}) + +test('plain-string multi-input manifest values are preserved', () => { + const { buildAllWorkflowExtensions } = loadModule() + const ext = extension('process', { + nodes: [{ id: 'p', name: 'Process', input: 'image', inputs: ['image', 'text'], + inputLabels: ['Source', 'Prompt'], output: 'mesh', paramsSchema: [] }], + }) + const [node] = buildAllWorkflowExtensions([], [ext]) + assert.deepEqual(node.inputs, ['image', 'text']) + assert.deepEqual(node.inputLabels, ['Source', 'Prompt']) +}) + +test('manifest without multi-input keeps inputs undefined', () => { + const { buildAllWorkflowExtensions } = loadModule() + assert.equal(buildAllWorkflowExtensions([], [extension('process')])[0].inputs, undefined) +}) diff --git a/src/areas/workflows/mockExtensions.ts b/src/areas/workflows/mockExtensions.ts index 9bdb9a0a..0adc3433 100644 --- a/src/areas/workflows/mockExtensions.ts +++ b/src/areas/workflows/mockExtensions.ts @@ -32,6 +32,14 @@ function applyParamDefaults( ) } +// Normalize untyped third-party extension manifests at their IPC boundary. +function normalizeInputs(raw: WorkflowExtension['inputs']): WorkflowExtension['inputs'] { + if (!raw) return raw + return raw.map((entry) => typeof entry === 'string' + ? entry + : (entry as unknown as { type: WorkflowExtension['input'] }).type) +} + export function buildAllWorkflowExtensions( modelExtensions: ModelExtension[], processExtensions: ProcessExtension[], @@ -50,7 +58,7 @@ export function buildAllWorkflowExtensions( name: node.name, description: ext.description ?? '', input: node.input, - inputs: node.inputs, + inputs: normalizeInputs(node.inputs), inputLabels: node.inputLabels, output: node.output, params: applyParamDefaults(node.paramsSchema as ParamSchema[], node.paramDefaults), @@ -72,7 +80,7 @@ export function buildAllWorkflowExtensions( name: node.name, description: ext.description ?? '', input: node.input, - inputs: node.inputs, + inputs: normalizeInputs(node.inputs), inputLabels: node.inputLabels, output: node.output, params: applyParamDefaults(node.paramsSchema as ParamSchema[], node.paramDefaults), diff --git a/src/areas/workflows/preflight.ts b/src/areas/workflows/preflight.ts index 068a9277..677ae78c 100644 --- a/src/areas/workflows/preflight.ts +++ b/src/areas/workflows/preflight.ts @@ -34,7 +34,8 @@ function formatType(type: DataType): string { if (type === 'mesh') return 'mesh' if (type === 'image') return 'image' if (type === 'audio') return 'audio' - return 'text' + if (type === 'text') return 'text' + return String(type) } function formatRequiredTypes(types: DataType[]): string { From c0beb4bb4504fe3c27abf8ce90cf0aed715367ed Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:28:18 +0200 Subject: [PATCH 03/11] integrate(#243): resizable asset library with thumbnails Adapted onto current Modly Next viewer/exporter; UI state conflict resolved explicitly. --- .../main/artifact-registry-service.test.ts | 27 ++++++ electron/main/artifact-registry-service.ts | 25 ++++++ .../preload/artifact-registry-preload.test.ts | 2 + electron/preload/electron-api.ts | 3 + src/areas/generate/GeneratePage.tsx | 82 +++++++++++++++++-- .../generate/assetLibraryService.test.ts | 19 +++++ src/areas/generate/assetLibraryService.ts | 9 ++ src/areas/generate/assetLibraryUi.test.ts | 12 +++ src/areas/generate/assetLibraryUi.ts | 29 +++++++ src/shared/types/assetLibrary.ts | 10 +++ src/shared/types/electron.d.ts | 3 + 11 files changed, 215 insertions(+), 6 deletions(-) diff --git a/electron/main/artifact-registry-service.test.ts b/electron/main/artifact-registry-service.test.ts index 276e90a1..3283a813 100644 --- a/electron/main/artifact-registry-service.test.ts +++ b/electron/main/artifact-registry-service.test.ts @@ -10,6 +10,7 @@ import { normalizeWorkspaceAssetPath, openWorkspaceAssetLibraryEntry, readWorkspaceAssetLibraryEntry, + readWorkspaceAssetLibraryThumbnail, registerWorkspaceAssetLibraryIpcHandlers, } from './artifact-registry-service.ts' @@ -210,6 +211,27 @@ test('fails closed for unsafe, self, missing, and mismatched sourceWorkspacePath assert.equal(!mismatched.success && mismatched.error.code, 'not-openable') })) +test('reads a sibling .thumb.png for GLB assets and stays silent when one is missing', () => withWorkspace(async (workspaceDir) => { + await mkdir(path.join(workspaceDir, 'Exports/props'), { recursive: true }) + await writeFile(path.join(workspaceDir, 'Exports/props/hero.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/props/hero.thumb.png'), 'fake-png-bytes') + await writeFile(path.join(workspaceDir, 'Exports/props/no-thumb.glb'), 'glb') + await writeFile(path.join(workspaceDir, 'Exports/static.ply'), 'ply') + + const withThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/hero.glb' }) + assert.equal(withThumb.success, true) + assert.equal(withThumb.success && withThumb.dataUrl, `data:image/png;base64,${Buffer.from('fake-png-bytes').toString('base64')}`) + + const withoutThumb = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/props/no-thumb.glb' }) + assert.equal(withoutThumb.success, false) + + const nonMesh = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: 'Exports/static.ply' }) + assert.equal(nonMesh.success, false) + + const unsafe = await readWorkspaceAssetLibraryThumbnail({ workspaceDir, workspacePath: '../secret.glb' }) + assert.equal(unsafe.success, false) +})) + test('IPC read and open handlers forward sourceWorkspacePath without trusting malformed payloads', async () => { const handlers = new Map Promise>() registerWorkspaceAssetLibraryIpcHandlers({ @@ -236,7 +258,12 @@ test('registers workspace library IPC handlers with structured results', async ( assert.equal(typeof handlers.get('workspace:library:list'), 'function') assert.equal(typeof handlers.get('workspace:library:read'), 'function') assert.equal(typeof handlers.get('workspace:library:open'), 'function') + assert.equal(typeof handlers.get('workspace:library:thumbnail'), 'function') const result = await handlers.get('workspace:library:read')?.({}, { workspacePath: '../escape.glb' }) assert.equal(typeof (result as { success?: unknown }).success, 'boolean') assert.equal((result as { success: boolean, error?: { code: string } }).error?.code, 'unsafe-path') + const thumbnail = await handlers.get('workspace:library:thumbnail')?.({}, { workspacePath: '../escape.glb' }) + assert.equal((thumbnail as { success: boolean }).success, false) + const missingPayload = await handlers.get('workspace:library:thumbnail')?.({}, {}) + assert.equal((missingPayload as { success: boolean }).success, false) }) diff --git a/electron/main/artifact-registry-service.ts b/electron/main/artifact-registry-service.ts index 2acb657f..c6d3232f 100644 --- a/electron/main/artifact-registry-service.ts +++ b/electron/main/artifact-registry-service.ts @@ -12,6 +12,7 @@ import type { AssetLibraryPreviewPayload, AssetLibraryReadResult, AssetLibrarySourceScope, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' import type { ArtifactProvenance } from '../../src/shared/types/artifacts' @@ -51,6 +52,10 @@ export interface WorkspaceAssetLibraryReadRequest extends WorkspaceAssetLibraryR sourceWorkspacePath?: string } +export interface WorkspaceAssetLibraryThumbnailRequest extends WorkspaceAssetLibraryRequest { + workspacePath: string +} + interface AssetLibraryMetadata { sourceWorkspacePath?: string manifestWorkspacePath?: string @@ -343,6 +348,21 @@ export async function openWorkspaceAssetLibraryEntry(request: WorkspaceAssetLibr return { success: true, entry: read.entry } } +// Thumbnails are pre-rendered beside their source mesh (see thumbs.py): a +// `.glb` asset may have a sibling `.thumb.png`. Missing files are +// a normal, silent condition, never surfaced as an AssetLibraryError. +export async function readWorkspaceAssetLibraryThumbnail(request: WorkspaceAssetLibraryThumbnailRequest): Promise { + try { + const normalized = normalizeWorkspaceAssetPath(request.workspaceDir, request.workspacePath) + if (!isGlbOrGltf(normalized.workspacePath)) return { success: false } + const thumbnailAbsolutePath = normalized.absolutePath.replace(/\.(glb|gltf)$/i, '.thumb.png') + const buffer = await readFile(thumbnailAbsolutePath) + return { success: true, dataUrl: `data:image/png;base64,${buffer.toString('base64')}` } + } catch { + return { success: false } + } +} + function readPayloadRequest(payload: unknown): { workspacePath?: string, sourceWorkspacePath?: string } { if (typeof payload !== 'object' || payload === null) return {} const values = payload as { workspacePath?: unknown, sourceWorkspacePath?: unknown } @@ -364,4 +384,9 @@ export function registerWorkspaceAssetLibraryIpcHandlers(deps: WorkspaceAssetLib if (!workspacePath) return { success: false, error: libraryError('invalid-request', 'workspacePath is required.') } return openWorkspaceAssetLibraryEntry({ workspaceDir: deps.getWorkspaceDir(), workspacePath, sourceWorkspacePath }) }) + deps.ipcMain.handle('workspace:library:thumbnail', async (_event, payload) => { + const { workspacePath } = readPayloadRequest(payload) + if (!workspacePath) return { success: false } + return readWorkspaceAssetLibraryThumbnail({ workspaceDir: deps.getWorkspaceDir(), workspacePath }) + }) } diff --git a/electron/preload/artifact-registry-preload.test.ts b/electron/preload/artifact-registry-preload.test.ts index 9dec46fd..e4a9fc76 100644 --- a/electron/preload/artifact-registry-preload.test.ts +++ b/electron/preload/artifact-registry-preload.test.ts @@ -24,6 +24,7 @@ test('preload exposes scoped workspace library list/read/open methods', async () workspacePath: 'Workflows/checkpoints/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', }) + await api.workspace.library.thumbnail({ workspacePath: 'Exports/hero.glb' }) assert.deepEqual(calls, [ { channel: 'workspace:library:list', payload: undefined }, @@ -41,6 +42,7 @@ test('preload exposes scoped workspace library list/read/open methods', async () sourceWorkspacePath: 'Workflows/checkpoints/hero.glb', }, }, + { channel: 'workspace:library:thumbnail', payload: { workspacePath: 'Exports/hero.glb' } }, ]) }) diff --git a/electron/preload/electron-api.ts b/electron/preload/electron-api.ts index f119582e..b6629e36 100644 --- a/electron/preload/electron-api.ts +++ b/electron/preload/electron-api.ts @@ -4,6 +4,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../src/shared/types/assetLibrary' export interface IpcRendererLike { @@ -231,6 +233,7 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra list: (): Promise => ipcRenderer.invoke('workspace:library:list') as Promise, read: (request: AssetLibraryReadRequest): Promise => ipcRenderer.invoke('workspace:library:read', request) as Promise, open: (request: AssetLibraryOpenRequest): Promise => ipcRenderer.invoke('workspace:library:open', request) as Promise, + thumbnail: (request: AssetLibraryThumbnailRequest): Promise => ipcRenderer.invoke('workspace:library:thumbnail', request) as Promise, }, }, diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 073ea487..98684fdd 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -13,12 +13,15 @@ import { resolveAssetLibraryOpenTarget, type ProjectedAssetLibraryEntry } from ' import { ASSET_LIBRARY_SORT_OPTIONS, buildAssetLibraryOpenRequest, + clampAssetLibraryPanelWidth, createAssetLibraryOpenJob, describeAssetLibraryOpenability, filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, + getStoredAssetLibraryPanelWidth, isAssetLibraryEntryOpenable, resolveOpenPanelAfterLibrarySelection, + storeAssetLibraryPanelWidth, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, type GenerateOpenPanel, @@ -468,6 +471,8 @@ function AssetLibraryPopover({ searchQuery, sortMode, collapsedSectionKeys, + thumbnails, + panelWidth, onSelectEntry, onSearchQueryChange, onSortModeChange, @@ -475,6 +480,7 @@ function AssetLibraryPopover({ onOpenSelected, onRefresh, onClose, + onResizeMouseDown, }: { entries: ProjectedAssetLibraryEntry[] selectedEntryId: string | null @@ -484,6 +490,9 @@ function AssetLibraryPopover({ searchQuery: string sortMode: AssetLibrarySortMode collapsedSectionKeys: string[] + /** Data URLs keyed by workspacePath, populated lazily as thumbnails load. */ + thumbnails: Record + panelWidth: number onSelectEntry: (entryId: string) => void onSearchQueryChange: (value: string) => void onSortModeChange: (value: AssetLibrarySortMode) => void @@ -491,6 +500,7 @@ function AssetLibraryPopover({ onOpenSelected: () => void onRefresh: () => void onClose: () => void + onResizeMouseDown: (event: React.MouseEvent) => void }) { const scopeGroups = filterAssetLibraryScopeGroups(entries, searchQuery, sortMode) const visibleEntryIds = new Set(scopeGroups.flatMap((scopeGroup) => scopeGroup.entryGroups.flatMap((group) => group.entries.map((entry) => entry.id)))) @@ -509,7 +519,8 @@ function AssetLibraryPopover({
@@ -525,6 +536,15 @@ function AssetLibraryPopover({
+ {/* Resize handle — drag to widen/narrow the library panel; size is persisted. */} +
+ ) })} @@ -675,8 +705,11 @@ export default function GeneratePage(): JSX.Element { const [librarySearchQuery, setLibrarySearchQuery] = useState('') const [librarySortMode, setLibrarySortMode] = useState('type') const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState(() => getDefaultAssetLibraryCollapsedSectionKeys()) + const [libraryThumbnails, setLibraryThumbnails] = useState>({}) + const [libraryPanelWidth, setLibraryPanelWidth] = useState(() => getStoredAssetLibraryPanelWidth()) const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null) const dragging = useRef(false) + const libraryPanelDragging = useRef(false) const [selectedPointLightId, setSelectedPointLightId] = useState(null) // Populated by Viewer3D — undoes the latest live gizmo transform, if any. const gizmoUndoRef = useRef<(() => boolean) | null>(null) @@ -758,6 +791,11 @@ export default function GeneratePage(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps -- lazy-load guarded by loaded/loading flags }, [openPanel, libraryLoaded, libraryLoading]) + // Persist the library panel's width so a manual resize survives app restarts. + useEffect(() => { + storeAssetLibraryPanelWidth(libraryPanelWidth) + }, [libraryPanelWidth]) + async function handleUnloadAll() { await window.electron.model.unloadAll() setUnloadStatus('done') @@ -863,6 +901,7 @@ export default function GeneratePage(): JSX.Element { ? current : result.entries.find(isAssetLibraryEntryOpenable)?.id ?? result.entries[0]?.id ?? null) setLibraryLoaded(true) + void loadLibraryThumbnails(result.entries) } catch (err) { setLibraryLoaded(false) setLibraryEntries([]) @@ -873,6 +912,17 @@ export default function GeneratePage(): JSX.Element { } } + // Thumbnails are best-effort: only .glb/.gltf entries can have a rendered + // .thumb.png, and a missing one is silently skipped rather than surfaced. + async function loadLibraryThumbnails(entries: ProjectedAssetLibraryEntry[]) { + const candidates = entries.filter((entry) => entry.previewKind === '3d-model') + const results = await Promise.all(candidates.map(async (entry) => { + const result = await assetLibraryService.thumbnail({ workspacePath: entry.workspacePath }) + return [entry.workspacePath, result.success ? result.dataUrl : null] as const + })) + setLibraryThumbnails(Object.fromEntries(results.filter((pair): pair is [string, string] => pair[1] !== null))) + } + async function handleOpenSelectedLibraryEntry() { const selectedEntry = libraryEntries.find((entry) => entry.id === librarySelectedEntryId) ?? null if (!selectedEntry) { @@ -959,6 +1009,23 @@ export default function GeneratePage(): JSX.Element { window.addEventListener('mouseup', onMouseUp) }, []) + const onLibraryPanelResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + libraryPanelDragging.current = true + + const onMouseMove = (ev: MouseEvent) => { + if (!libraryPanelDragging.current) return + setLibraryPanelWidth((w) => clampAssetLibraryPanelWidth(w + ev.movementX)) + } + const onMouseUp = () => { + libraryPanelDragging.current = false + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + } + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + }, []) + return ( <>
@@ -1082,6 +1149,8 @@ export default function GeneratePage(): JSX.Element { searchQuery={librarySearchQuery} sortMode={librarySortMode} collapsedSectionKeys={libraryCollapsedSectionKeys} + thumbnails={libraryThumbnails} + panelWidth={libraryPanelWidth} onSelectEntry={(entryId) => { setLibraryError(null) setLibrarySelectedEntryId(entryId) @@ -1092,6 +1161,7 @@ export default function GeneratePage(): JSX.Element { onOpenSelected={() => { void handleOpenSelectedLibraryEntry() }} onRefresh={() => { void loadLibraryEntries() }} onClose={() => setOpenPanel(null)} + onResizeMouseDown={onLibraryPanelResizeMouseDown} /> )}
diff --git a/src/areas/generate/assetLibraryService.test.ts b/src/areas/generate/assetLibraryService.test.ts index 9e7938c4..0ac08bce 100644 --- a/src/areas/generate/assetLibraryService.test.ts +++ b/src/areas/generate/assetLibraryService.test.ts @@ -50,6 +50,7 @@ test('renderer service delegates safe IPC calls and normalizes returned entries' }), read: async () => ({ success: false, error: { code: 'missing', message: 'Missing' } }), open: async (request) => { openRequest = request; return { success: false, error: { code: 'missing', message: 'Missing' } } }, + thumbnail: async () => ({ success: false }), }) const result = await service.list() @@ -58,3 +59,21 @@ test('renderer service delegates safe IPC calls and normalizes returned entries' await service.open({ workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' }) assert.deepEqual(openRequest, { workspacePath: 'Workflows/hero.landmarks.v1.json', sourceWorkspacePath: 'Workflows/hero.glb' }) }) + +test('renderer service validates thumbnail requests before IPC and never surfaces an error', async () => { + let invoked = false + const service = createAssetLibraryService({ + list: async () => ({ success: true, entries: [] }), + read: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }), + open: async () => ({ success: false, error: { code: 'unexpected', message: 'should not run' } }), + thumbnail: async () => { invoked = true; return { success: true, dataUrl: 'data:image/png;base64,ok' } }, + }) + + const unsafe = await service.thumbnail({ workspacePath: '../escape.glb' }) + assert.deepEqual(unsafe, { success: false }) + assert.equal(invoked, false) + + const safe = await service.thumbnail({ workspacePath: 'Exports/hero.glb' }) + assert.deepEqual(safe, { success: true, dataUrl: 'data:image/png;base64,ok' }) + assert.equal(invoked, true) +}) diff --git a/src/areas/generate/assetLibraryService.ts b/src/areas/generate/assetLibraryService.ts index 38738579..5c18d49d 100644 --- a/src/areas/generate/assetLibraryService.ts +++ b/src/areas/generate/assetLibraryService.ts @@ -5,6 +5,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from '../../shared/types/assetLibrary' import { projectAssetLibraryEntry, type ProjectedAssetLibraryEntry } from './assetLibraryProjection' @@ -12,6 +14,7 @@ export interface AssetLibraryPreloadApi { list: () => Promise read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise + thumbnail: (request: AssetLibraryThumbnailRequest) => Promise } export type ProjectedAssetLibraryListResult = @@ -60,6 +63,12 @@ export function createAssetLibraryService(api: AssetLibraryPreloadApi) { if (!result.success) return result return { success: true, entry: projectAssetLibraryEntry(result.entry) } }, + // A missing/invalid thumbnail is never an error the UI needs to show — the + // Library list just falls back to its text-only row. + async thumbnail(request: AssetLibraryThumbnailRequest): Promise { + if (validateWorkspacePath(request.workspacePath)) return { success: false } + return api.thumbnail(request) + }, } } diff --git a/src/areas/generate/assetLibraryUi.test.ts b/src/areas/generate/assetLibraryUi.test.ts index e6b9fbca..681c292e 100644 --- a/src/areas/generate/assetLibraryUi.test.ts +++ b/src/areas/generate/assetLibraryUi.test.ts @@ -3,12 +3,15 @@ import test from 'node:test' import { buildAssetLibraryOpenRequest, + clampAssetLibraryPanelWidth, createAssetLibraryOpenJob, describeAssetLibraryOpenability, filterAssetLibraryScopeGroups, getDefaultAssetLibraryCollapsedSectionKeys, + getStoredAssetLibraryPanelWidth, isAssetLibraryEntryOpenable, resolveOpenPanelAfterLibrarySelection, + storeAssetLibraryPanelWidth, toggleAssetLibrarySectionKey, type AssetLibrarySortMode, type GenerateOpenPanel, @@ -123,3 +126,12 @@ test('builds linked-source open requests and import jobs for safe sidecars', () assert.equal(selection?.historyUrl, '/workspace/Workflows/run/hero.glb') assert.equal(selection?.job.outputUrl, '/workspace/Workflows/run/hero.glb') }) + +test('Library panel width clamps to sane bounds and degrades silently without a localStorage', () => { + assert.equal(clampAssetLibraryPanelWidth(0), 260) + assert.equal(clampAssetLibraryPanelWidth(999), 560) + assert.equal(clampAssetLibraryPanelWidth(400), 400) + // node --test has no `localStorage` global — both helpers must fall back rather than throw. + assert.equal(getStoredAssetLibraryPanelWidth(), 320) + assert.doesNotThrow(() => storeAssetLibraryPanelWidth(400)) +}) diff --git a/src/areas/generate/assetLibraryUi.ts b/src/areas/generate/assetLibraryUi.ts index 5e83e65a..295dd185 100644 --- a/src/areas/generate/assetLibraryUi.ts +++ b/src/areas/generate/assetLibraryUi.ts @@ -168,6 +168,35 @@ export function resolveOpenPanelAfterLibrarySelection(currentPanel: GenerateOpen return currentPanel === 'library' ? 'library' : currentPanel } +export const ASSET_LIBRARY_PANEL_MIN_WIDTH = 260 +export const ASSET_LIBRARY_PANEL_MAX_WIDTH = 560 +export const ASSET_LIBRARY_PANEL_DEFAULT_WIDTH = 320 + +const ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY = 'modly-library-panel-width' + +export function clampAssetLibraryPanelWidth(width: number): number { + return Math.min(ASSET_LIBRARY_PANEL_MAX_WIDTH, Math.max(ASSET_LIBRARY_PANEL_MIN_WIDTH, width)) +} + +/** Reads the user's saved Library panel width, falling back to the default when unset, invalid, or unreadable. */ +export function getStoredAssetLibraryPanelWidth(): number { + try { + const raw = Number(localStorage.getItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(raw) && raw > 0 ? clampAssetLibraryPanelWidth(raw) : ASSET_LIBRARY_PANEL_DEFAULT_WIDTH + } catch { + return ASSET_LIBRARY_PANEL_DEFAULT_WIDTH + } +} + +/** Persists the Library panel width so it survives app restarts. */ +export function storeAssetLibraryPanelWidth(width: number): void { + try { + localStorage.setItem(ASSET_LIBRARY_PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Ignore quota/private-mode failures — the panel just won't remember its size. + } +} + function sortAssetLibraryEntries( entries: ProjectedAssetLibraryEntry[], sortMode: AssetLibrarySortMode, diff --git a/src/shared/types/assetLibrary.ts b/src/shared/types/assetLibrary.ts index fb5a602d..87c48785 100644 --- a/src/shared/types/assetLibrary.ts +++ b/src/shared/types/assetLibrary.ts @@ -81,3 +81,13 @@ export type AssetLibraryReadResult = export type AssetLibraryOpenResult = | { success: true, entry: AssetLibraryEntry } | { success: false, error: AssetLibraryError } + +export interface AssetLibraryThumbnailRequest { + workspacePath: string +} + +// Missing thumbnails are an expected, silent condition (not every asset has +// a rendered preview), so failure carries no error payload for the UI to show. +export type AssetLibraryThumbnailResult = + | { success: true, dataUrl: string } + | { success: false } diff --git a/src/shared/types/electron.d.ts b/src/shared/types/electron.d.ts index c171b664..ab907c17 100644 --- a/src/shared/types/electron.d.ts +++ b/src/shared/types/electron.d.ts @@ -7,6 +7,8 @@ import type { AssetLibraryOpenResult, AssetLibraryReadRequest, AssetLibraryReadResult, + AssetLibraryThumbnailRequest, + AssetLibraryThumbnailResult, } from './assetLibrary' // ─── Extension types ────────────────────────────────────────────────────────── @@ -297,6 +299,7 @@ declare global { list: () => Promise read: (request: AssetLibraryReadRequest) => Promise open: (request: AssetLibraryOpenRequest) => Promise + thumbnail: (request: AssetLibraryThumbnailRequest) => Promise } } setup: { From 7264ac59d632b0af2ab49977b04840b6a10614f1 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:30:50 +0200 Subject: [PATCH 04/11] fix(viewer): repair malformed animation clip fallback from #242 --- src/areas/generate/components/Viewer3D.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index 0d43d5c2..e3de5d6b 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -316,7 +316,7 @@ function GltfMeshModel(props: MeshModelProps): JSX.Element { className="bg-zinc-800 text-zinc-200 border border-zinc-700 rounded px-2 py-1 text-xs" > {animations.map((clip, i) => ( - + ))} From ac6ac8a9de7bbe3b5c776d5695b3c3432f6df205 Mon Sep 17 00:00:00 2001 From: JayceeB1 <45721154+JayceeB1@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:33:14 +0200 Subject: [PATCH 05/11] integrate(#245): desktop UI, animation motion bar and exporter metadata Manually reconcile viewer/param panel with 0.4.3 APIs; omit obsolete package-lock refresh. --- electron/main/index.ts | 59 +++++++++ src/areas/generate/GeneratePage.tsx | 42 +++++- src/areas/generate/components/MotionBar.tsx | 48 +++++++ src/areas/generate/components/Viewer3D.tsx | 85 +++++++----- .../generate/components/WorkflowPanel.tsx | 124 +++++++++++++++--- .../nodes/mesh-exporter/manifest.json | 21 +++ .../nodes/mesh-exporter/processor.ts | 74 +++++++++-- src/shared/types/electron.d.ts | 2 +- 8 files changed, 391 insertions(+), 64 deletions(-) create mode 100644 src/areas/generate/components/MotionBar.tsx diff --git a/electron/main/index.ts b/electron/main/index.ts index 2f3f5b83..5b28bcb3 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, shell, session } from 'electron' import { join } from 'path' +import { readFileSync, writeFileSync } from 'fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { setupIpcHandlers } from './ipc-handlers' import { PythonBridge } from './python-bridge' @@ -18,6 +19,37 @@ let pythonBridge: PythonBridge | null = null process.stdout?.on('error', () => {}) process.stderr?.on('error', () => {}) +// UI zoom: Ctrl/Cmd with + / - / 0 scales the whole window like a browser, +// clamped to a sane range and persisted across restarts. This is done at the +// Chromium level (not CSS) so pointer math in the 3D viewport stays correct. +const ZOOM_MIN = -2 +const ZOOM_MAX = 4 + +function zoomFilePath(): string { + return join(app.getPath('userData'), 'ui-zoom.json') +} + +function loadZoomLevel(): number { + try { + const saved = JSON.parse(readFileSync(zoomFilePath(), 'utf-8')) as { level?: number } + return typeof saved.level === 'number' ? saved.level : 0 + } catch { + return 0 + } +} + +function saveZoomLevel(level: number): void { + try { + writeFileSync(zoomFilePath(), JSON.stringify({ level }), 'utf-8') + } catch (err) { + logger.error(`Failed to persist UI zoom level: ${err}`) + } +} + +function clampZoomLevel(level: number): number { + return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, level)) +} + function createWindow(): void { mainWindow = new BrowserWindow({ width: 1280, @@ -60,6 +92,33 @@ function createWindow(): void { event.preventDefault() app.quit() } + + const isZoomChord = input.type === 'keyDown' && (input.control || input.meta) && !input.alt + if (!isZoomChord) return + + const wc = mainWindow?.webContents + if (!wc) return + + if (input.key === '+' || input.key === '=') { + event.preventDefault() + const level = clampZoomLevel(wc.getZoomLevel() + 0.5) + wc.setZoomLevel(level) + saveZoomLevel(level) + } else if (input.key === '-' || input.key === '_') { + event.preventDefault() + const level = clampZoomLevel(wc.getZoomLevel() - 0.5) + wc.setZoomLevel(level) + saveZoomLevel(level) + } else if (input.key === '0') { + event.preventDefault() + wc.setZoomLevel(0) + saveZoomLevel(0) + } + }) + + mainWindow.webContents.on('did-finish-load', () => { + const level = loadZoomLevel() + if (level) mainWindow?.webContents.setZoomLevel(level) }) mainWindow.webContents.setWindowOpenHandler((details) => { diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 98684fdd..45d75907 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -28,9 +28,32 @@ import { } from './assetLibraryUi' const MIN_WIDTH = 220 -const MAX_WIDTH = 520 +const MAX_WIDTH = 900 const DEFAULT_WIDTH = 320 +const PANEL_WIDTH_STORAGE_KEY = 'modly-panel-width' + +function clampPanelWidth(width: number): number { + return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width)) +} + +function getStoredPanelWidth(): number { + try { + const raw = Number(localStorage.getItem(PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(raw) && raw > 0 ? clampPanelWidth(raw) : DEFAULT_WIDTH + } catch { + return DEFAULT_WIDTH + } +} + +function storePanelWidth(width: number): void { + try { + localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(width)) + } catch { + // Resizing still works if persisted settings are unavailable. + } +} + const MAX_POINT_LIGHTS = 6 function createPointLight(): PointLight { @@ -691,7 +714,7 @@ function AssetLibraryPopover({ export default function GeneratePage(): JSX.Element { const [unloadStatus, setUnloadStatus] = useState<'idle' | 'done'>('idle') - const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH) + const [panelWidth, setPanelWidth] = useState(() => getStoredPanelWidth()) const [openPanel, setOpenPanel] = useState(null) const [decimating, setDecimating] = useState(false) const [smoothing, setSmoothing] = useState(false) @@ -796,6 +819,11 @@ export default function GeneratePage(): JSX.Element { storeAssetLibraryPanelWidth(libraryPanelWidth) }, [libraryPanelWidth]) + // Persist the generate options panel's width so a manual resize survives app restarts. + useEffect(() => { + storePanelWidth(panelWidth) + }, [panelWidth]) + async function handleUnloadAll() { await window.electron.model.unloadAll() setUnloadStatus('done') @@ -998,7 +1026,7 @@ export default function GeneratePage(): JSX.Element { const onMouseMove = (ev: MouseEvent) => { if (!dragging.current) return - setPanelWidth((w) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, w + ev.movementX))) + setPanelWidth((w) => clampPanelWidth(w + ev.movementX)) } const onMouseUp = () => { dragging.current = false @@ -1032,10 +1060,14 @@ export default function GeneratePage(): JSX.Element {
- {/* Resize handle */} + {/* Resize handle — drag to widen/narrow the generate options panel; size is persisted. */}
diff --git a/src/areas/generate/components/MotionBar.tsx b/src/areas/generate/components/MotionBar.tsx new file mode 100644 index 00000000..293f2435 --- /dev/null +++ b/src/areas/generate/components/MotionBar.tsx @@ -0,0 +1,48 @@ +export interface MotionClip { + name: string +} + +interface MotionBarProps { + clips: MotionClip[] + activeIndex: number + onChange: (index: number) => void +} + +function clipLabel(clip: MotionClip, index: number): string { + return clip.name || `Motion ${index + 1}` +} + +/** Bottom-left overlay for scrubbing a rigged model's animation clips. */ +export function MotionBar({ clips, activeIndex, onChange }: MotionBarProps): JSX.Element | null { + if (clips.length === 0) return null + + if (clips.length === 1) { + return ( +
+ Playing + {clipLabel(clips[0], 0)} +
+ ) + } + + return ( +
+ Motion + + +
+ ) +} diff --git a/src/areas/generate/components/Viewer3D.tsx b/src/areas/generate/components/Viewer3D.tsx index e3de5d6b..0624b939 100644 --- a/src/areas/generate/components/Viewer3D.tsx +++ b/src/areas/generate/components/Viewer3D.tsx @@ -2,7 +2,7 @@ import { Component, Suspense, useCallback, useEffect, useMemo, useRef, useState import type { ReactNode, ErrorInfo, MutableRefObject } from 'react' import { Canvas, useFrame, useLoader, useThree } from '@react-three/fiber' import type { ThreeEvent } from '@react-three/fiber' -import { Environment, GizmoHelper, Html, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' +import { Environment, GizmoHelper, Lightformer, OrbitControls, useGizmoContext, useGLTF } from '@react-three/drei' import { EffectComposer, Outline, Select, Selection } from '@react-three/postprocessing' import * as THREE from 'three' import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js' @@ -20,6 +20,7 @@ import SplatViewer, { type SplatViewerHandle } from './SplatViewer' import { useGeneration } from '@shared/hooks/useGeneration' import { useAppStore } from '@shared/stores/appStore' import { ViewerToolbar, type ViewMode } from './ViewerToolbar' +import { MotionBar, type MotionClip } from './MotionBar' import type { LightSettings, PointLight } from '@shared/stores/appStore' import { DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' @@ -259,6 +260,8 @@ interface MeshModelProps { onStats: (stats: { vertices: number; triangles: number }) => void onSelect: () => void onObject: (obj: THREE.Object3D | null) => void + onClips: (clips: MotionClip[]) => void + clipIndex: number } function centerSceneOnGrid(scene: THREE.Object3D): void { @@ -274,55 +277,64 @@ function centerSceneOnGrid(scene: THREE.Object3D): void { scene.updateMatrixWorld(true) } -function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject }: MeshModelProps): JSX.Element { +function MeshModel({ url, jobId, viewMode, selected, onStats, onSelect, onObject, onClips, clipIndex }: MeshModelProps): JSX.Element { const extension = url.split('?')[0]?.split('.').pop()?.toLowerCase() - const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject } + const common = { url, jobId, viewMode, selected, onStats, onSelect, onObject, onClips, clipIndex } return extension === 'obj' ? : } function GltfMeshModel(props: MeshModelProps): JSX.Element { const gltf = useGLTF(props.url) const { scene, animations } = gltf - const mixerRef = useRef(null) - const [clipIndex, setClipIndex] = useState(0) + const mixerRef = useRef(null) + const actionRef = useRef(null) + const { onClips, clipIndex } = props // Rigged models arrive with one or more clips; without a mixer they render - // frozen in bind pose, which reads as "the rig failed". + // frozen in bind pose, which reads as "the rig failed". The mixer lives for + // as long as this model does — clip switches below reuse it. useEffect(() => { if (!animations?.length) { mixerRef.current = null + actionRef.current = null return } const mixer = new THREE.AnimationMixer(scene) - mixer.clipAction(animations[Math.min(clipIndex, animations.length - 1)]).play() mixerRef.current = mixer return () => { mixer.stopAllAction() mixer.uncacheRoot(scene) mixerRef.current = null + actionRef.current = null } - }, [scene, animations, clipIndex]) + }, [scene, animations]) + + // Tell the viewer which clips are available so it can render the motion bar. + useEffect(() => { + onClips(animations?.map((clip) => ({ name: clip.name })) ?? []) + // eslint-disable-next-line react-hooks/exhaustive-deps -- onClips is a stable callback + }, [animations]) + + // Switch to the selected clip, cross-fading so playback never hard-cuts. + useEffect(() => { + const mixer = mixerRef.current + if (!mixer || !animations?.length) return + const clip = animations[Math.min(clipIndex, animations.length - 1)] + const next = mixer.clipAction(clip) + next.reset().setLoop(THREE.LoopRepeat, Infinity) + const prev = actionRef.current + if (prev && prev !== next) { + prev.fadeOut(0.25) + next.fadeIn(0.25).play() + } else { + next.play() + } + actionRef.current = next + }, [animations, clipIndex]) useFrame((_state, delta) => mixerRef.current?.update(delta)) - return ( - <> - - {animations && animations.length > 1 && ( - - - - )} - - ) + return } function ObjMeshModel(props: MeshModelProps): JSX.Element { @@ -1052,6 +1064,8 @@ export default function Viewer3D({ const [viewMode, setViewMode] = useState('solid') const [autoRotate, setAutoRotate] = useState(false) + const [clips, setClips] = useState([]) + const [activeClipIndex, setActiveClipIndex] = useState(0) const selected = useAppStore((s) => s.meshSelected) const setSelected = useAppStore((s) => s.setMeshSelected) const canvasRef = useRef(null) @@ -1095,6 +1109,8 @@ export default function Viewer3D({ setSelected(false) setViewMode('solid') setStoreMeshStats(null) + setClips([]) + setActiveClipIndex(0) // eslint-disable-next-line react-hooks/exhaustive-deps -- reset only when the model changes; setters are stable }, [modelUrl]) @@ -1303,6 +1319,8 @@ export default function Viewer3D({ onStats={setStoreMeshStats} onSelect={handleMeshSelect} onObject={setMeshObject} + onClips={setClips} + clipIndex={activeClipIndex} /> @@ -1366,12 +1384,15 @@ export default function Viewer3D({ /> )} - {/* Bottom-left stats overlay */} - {meshStats && ( -
-

- {meshStats.triangles.toLocaleString()} tri • {meshStats.vertices.toLocaleString()} verts -

+ {/* Bottom-left overlays — mesh stats and the motion bar */} + {(meshStats || clips.length > 0) && ( +
+ {meshStats && ( +

+ {meshStats.triangles.toLocaleString()} tri • {meshStats.vertices.toLocaleString()} verts +

+ )} +
)} diff --git a/src/areas/generate/components/WorkflowPanel.tsx b/src/areas/generate/components/WorkflowPanel.tsx index 14b25781..472ef125 100644 --- a/src/areas/generate/components/WorkflowPanel.tsx +++ b/src/areas/generate/components/WorkflowPanel.tsx @@ -58,6 +58,83 @@ function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNo return result } +// ─── Tag suggestions ────────────────────────────────────────────────────────── + +const TAG_STOPWORDS = new Set([ + 'a', 'an', 'the', 'of', 'for', 'and', 'or', 'to', 'in', 'on', 'my', 'this', 'is', 'with', +]) + +const FIXED_TAG_VOCAB = [ + 'prop', 'character', 'creature', 'environment', 'weapon', + 'furniture', 'hero-asset', 'background', 'low-poly', 'stylized', 'realistic', +] + +const MAX_TAG_SUGGESTIONS = 10 + +/** Slugified words pulled from the typed name/project, plus a small fixed vocabulary — capped and deduped. */ +function suggestedTags(modelName: string, project: string): string[] { + const fromFields = `${modelName} ${project}` + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((word) => word.length > 1 && !TAG_STOPWORDS.has(word)) + + const seen = new Set() + const suggestions: string[] = [] + for (const tag of [...fromFields, ...FIXED_TAG_VOCAB]) { + if (!tag || seen.has(tag)) continue + seen.add(tag) + suggestions.push(tag) + if (suggestions.length >= MAX_TAG_SUGGESTIONS) break + } + return suggestions +} + +function parseTags(value: string): string[] { + return value.split(',').map((s) => s.trim()).filter(Boolean) +} + +/** Appends a tag to the comma-separated value, or removes it if already present. */ +function toggleTag(value: string, tag: string): string { + const tags = parseTags(value) + const i = tags.indexOf(tag) + if (i >= 0) tags.splice(i, 1) + else tags.push(tag) + return tags.join(', ') +} + + +function TagSuggestionPills({ value, onChange, modelName, project }: { + value: string + onChange: (v: string) => void + modelName: string + project: string +}) { + const suggestions = useMemo(() => suggestedTags(modelName, project), [modelName, project]) + if (suggestions.length === 0) return null + + const active = new Set(parseTags(value)) + + return ( +
+ {suggestions.map((tag) => ( + + ))} +
+ ) +} + + // ─── Param field ────────────────────────────────────────────────────────────── const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60' @@ -201,7 +278,7 @@ function ImageParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: Flow - Image + Your sketch or photo — required
{preview ? (