// Workspace → Retopo module. Run on a stored mesh: // - Auto Retopo: check the mesh, Repair its non-manifold edges, then rebuild // clean topology (optionally as a watertight shell) — Mesh Editor tools, // src/utils/workspaceRetopo.js; // - Optimize: decimate with gltfpack, keeping UVs / texture; // - Refine: rebuild the mesh's detail from a reference image with a ComfyUI // workflow (Refine Mesh with Trellis2 / Hi3D — isRefineWorkflow). // Every result is saved as a new VERSION of the source mesh, logged to the // history, and becomes the panel's source — so the tools chain. import { useEffect, useMemo, useState } from 'react' import { useProjects } from '../../context/ProjectContext' import { useNotifications } from '../../context/NotificationContext' import { useWorkflowJobs } from '../../context/WorkflowJobsContext' import { assetUrl } from '../../config' import { createComfyExecutionId } from '../../utils/ids' import { DEFAULT_AUTO_RETOPO_OPTIONS, DEFAULT_OPTIMIZE_OPTIONS, DEFAULT_REPAIR_OPTIONS } from '../../utils/meshTools' import { createMeshThumbnailFile, createMeshThumbnailFileFromUrl } from '../../utils/meshThumbnail' import { fetchComfyWorkflows, resolveSourceReference, uploadAssetThumbnail } from '../../utils/workspaceApi' import { coerceParameterValue, defaultParameterValues, getParameterValueType, randomSeed } from '../../utils/workspaceImage' import { FACE_PRESETS, formatFaceCount, snapToEnums } from '../../utils/workspaceMesh' import { filterRefineWorkflows, refineWorkflowLabel, resolveRefineRoles } from '../../utils/workspaceTexture' import WorkspaceImageSlot from './WorkspaceImageSlot' import WorkspaceParamField from './WorkspaceParamField' import { OPTIMIZE_RATIO_PRESETS, RETOPO_FACE_PRESETS, RETOPO_TOOLS, checkMesh, describeResult, runRetopoTool, saveMeshVersion, versionName } from '../../utils/workspaceRetopo' import AssetSelectorModal from '../AssetSelectorModal' import WorkspaceMeshSlot from './WorkspaceMeshSlot' import { Disclosure, NumberInput, ResultRows, Select, Switch } from './WorkspaceControls' const SETTINGS_KEY = 'workspace.retopo.settings.v1' const TABS = [ { id: 'autoretopo', label: 'Auto Retopo', icon: 'grid_4x4' }, { id: 'optimize', label: 'Optimize', icon: 'compress' }, { id: 'refine', label: 'Refine', icon: 'auto_fix_high' } ] const TAB_IDS = TABS.map(entry => entry.id) const DEFAULT_REFINE = { workflowId: '', faces: null, // null = the workflow's own default randomSeed: true } const DEFAULT_SETTINGS = { tab: 'autoretopo', retopo: DEFAULT_AUTO_RETOPO_OPTIONS, repair: DEFAULT_REPAIR_OPTIONS, optimize: DEFAULT_OPTIMIZE_OPTIONS, refine: DEFAULT_REFINE } function refinedName(asset) { const base = String(asset?.name || 'Mesh').replace(/\.[^.]+$/, '').replace(/\s*\((retopo|repaired|optimized|refined)\)$/i, '') return `${base} (refined)` } function readSettings() { try { const parsed = JSON.parse(window.localStorage.getItem(SETTINGS_KEY) || 'null') if (!parsed || typeof parsed !== 'object') return DEFAULT_SETTINGS return { tab: TAB_IDS.includes(parsed.tab) ? parsed.tab : 'autoretopo', retopo: { ...DEFAULT_AUTO_RETOPO_OPTIONS, ...(parsed.retopo || {}) }, repair: { ...DEFAULT_REPAIR_OPTIONS, ...(parsed.repair || {}) }, optimize: { ...DEFAULT_OPTIMIZE_OPTIONS, ...(parsed.optimize || {}) }, refine: { ...DEFAULT_REFINE, ...(parsed.refine || {}) } } } catch { return DEFAULT_SETTINGS } } function writeSettings(settings) { try { window.localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) } catch { // storage unavailable — nothing to keep } } const CHECK_STATUS_ICONS = { pass: 'check_circle', warn: 'warning', fail: 'error' } export default function WorkspaceRetopoPanel({ target, targetBusy, importers, imageImporters, onTargetChange, selectedMeshEntry, selectedImageEntry, onResult, onPendingAdd, onPendingUpdate, onPendingRemove, reuse, projectId, projectName }) { const { runComfyWorkflow } = useProjects() const { addNotification } = useNotifications() const { registerJob, completeJob } = useWorkflowJobs() const [settings, setSettings] = useState(readSettings) const [running, setRunning] = useState(null) // tool id const [progress, setProgress] = useState(null) // { frac, message } const [status, setStatus] = useState(null) const [lastResult, setLastResult] = useState(null) // { label, rows, assetId } // Refine: the installed workflows, the reference image, per-workflow values. const [workflows, setWorkflows] = useState([]) const [workflowsLoading, setWorkflowsLoading] = useState(true) const [workflowsError, setWorkflowsError] = useState('') const [workflowsVersion, setWorkflowsVersion] = useState(0) const [refineImage, setRefineImage] = useState(null) const [refineImageBusy, setRefineImageBusy] = useState(false) const [valuesByWorkflow, setValuesByWorkflow] = useState({}) const [imagePicker, setImagePicker] = useState(false) const [check, setCheck] = useState(null) // { assetId, loading, result, error } const [assetPicker, setAssetPicker] = useState(false) const [slotBusy, setSlotBusy] = useState(false) const [appliedReuseNonce, setAppliedReuseNonce] = useState(null) useEffect(() => { writeSettings(settings) }, [settings]) useEffect(() => { let cancelled = false fetchComfyWorkflows() .then(list => { if (cancelled) return setWorkflows(Array.isArray(list) ? list : []) setWorkflowsError('') }) .catch(err => { if (!cancelled) setWorkflowsError(err.message || 'Failed to load ComfyUI workflows') }) .finally(() => { if (!cancelled) setWorkflowsLoading(false) }) return () => { cancelled = true } }, [workflowsVersion]) // "Reuse settings" on a Retopo history entry: its tool and options. if (reuse && reuse.nonce !== appliedReuseNonce) { setAppliedReuseNonce(reuse.nonce) const params = reuse.entry.params || {} if (params.tool === 'refine') { setSettings(prev => ({ ...prev, tab: 'refine', refine: { ...prev.refine, workflowId: params.workflowId ? String(params.workflowId) : prev.refine.workflowId, faces: Number(params.faces) || prev.refine.faces } })) if (params.workflowId && params.values) { setValuesByWorkflow(prev => ({ ...prev, [params.workflowId]: { ...(prev[params.workflowId] || {}), ...params.values } })) } } else { const key = params.tool === 'optimize' ? 'optimize' : params.tool === 'repair' ? 'repair' : 'retopo' setSettings(prev => ({ ...prev, tab: params.tool === 'optimize' ? 'optimize' : 'autoretopo', [key]: { ...prev[key], ...(params.options || {}) } })) } } const tab = TAB_IDS.includes(settings.tab) ? settings.tab : 'autoretopo' const retopo = settings.retopo const repair = settings.repair const optimize = settings.optimize const refine = settings.refine const setOptions = (key, patch) => setSettings(prev => ({ ...prev, [key]: { ...prev[key], ...patch } })) const refineWorkflows = useMemo(() => filterRefineWorkflows(workflows), [workflows]) const refineWorkflow = refineWorkflows.find(wf => String(wf.id) === String(refine.workflowId)) || refineWorkflows[0] || null const refineRoles = useMemo(() => (refineWorkflow ? resolveRefineRoles(refineWorkflow) : null), [refineWorkflow]) const refineValues = refineWorkflow ? (valuesByWorkflow[refineWorkflow.id] || defaultParameterValues(refineWorkflow)) : {} const refineFaces = refineRoles?.faces ? (refine.faces ?? (Number(refineValues[refineRoles.faces.id]) || null)) : null const setRefineValue = (parameterId, value) => { if (!refineWorkflow) return setValuesByWorkflow(prev => ({ ...prev, [refineWorkflow.id]: { ...(prev[refineWorkflow.id] || defaultParameterValues(refineWorkflow)), [parameterId]: value } })) } const busy = Boolean(running) || targetBusy || slotBusy || refineImageBusy const currentCheck = check && check.assetId === target?.assetId ? check : null const currentResult = lastResult && lastResult.assetId === target?.assetId ? lastResult : null const fillTarget = async (load) => { setSlotBusy(true) try { onTargetChange(await load()) setStatus(null) } catch (err) { setStatus({ tone: 'error', message: err.message || 'Could not use that mesh.' }) } finally { setSlotBusy(false) } } const runCheck = async () => { if (!target) return const assetId = target.assetId setCheck({ assetId, loading: true }) try { setCheck({ assetId, loading: false, result: await checkMesh({ id: assetId, ...target }) }) } catch (err) { setCheck({ assetId, loading: false, error: err.message || 'The check failed.' }) } } const runTool = async (toolId, options) => { if (!target || running) return const runTarget = target const tool = RETOPO_TOOLS[toolId] const pendingId = createComfyExecutionId('workspace-retopo') setRunning(toolId) setStatus(null) setProgress({ frac: 0, message: `${tool.label}…` }) onPendingAdd?.({ id: pendingId, label: `${tool.label} · ${runTarget.name}`, cloud: true, stoppable: false, progressPercent: 0 }) try { const { blob, stats } = await runRetopoTool(toolId, { id: runTarget.assetId, ...runTarget }, options, { onProgress: (evt) => { setProgress(evt) const frac = Number(evt?.frac) if (Number.isFinite(frac)) onPendingUpdate?.(pendingId, { progressPercent: Math.round(frac * 90) }) } }) setProgress({ frac: 0.95, message: 'Saving the new version…' }) onPendingUpdate?.(pendingId, { progressPercent: 95 }) let saved = await saveMeshVersion({ id: runTarget.assetId, name: runTarget.name }, blob, versionName(runTarget, toolId)) // A version starts with its parent's thumbnail; the topology changed, so // render the real one (best-effort). try { const thumbnail = await createMeshThumbnailFile(new File([blob], 'mesh.glb', { type: 'model/gltf-binary' })) if (thumbnail) saved = { ...saved, ...(await uploadAssetThumbnail(saved.id, thumbnail)) } } catch (err) { console.warn('Could not render the new version thumbnail', err) } const rows = describeResult(toolId, stats) await onResult({ asset: saved, toolId, options, rows, source: runTarget }) setLastResult({ label: tool.label, rows, assetId: saved.id }) } catch (err) { console.error(`Workspace ${tool.label} failed`, err) setStatus({ tone: 'error', message: err.message || `${tool.label} failed.` }) addNotification({ title: `${tool.label} failed`, message: err.message || `${tool.label} failed`, tone: 'error', source: tool.label, projectId }) } finally { setRunning(null) setProgress(null) onPendingRemove?.(pendingId) } } const fillRefineImage = async (load) => { setRefineImageBusy(true) try { setRefineImage(await load()) setStatus(null) } catch (err) { setStatus({ tone: 'error', message: err.message || 'Could not use that image.' }) } finally { setRefineImageBusy(false) } } const refineBlocker = (() => { if (!refineWorkflow) return workflowsLoading ? 'Loading workflows…' : 'No refine workflow installed' if (!refineImage?.sourceReference) return 'Choose the reference image' return null })() // A ComfyUI run (not the Mesh Tools service): the job store tracks it, so it // shows in the history with ComfyUI's own progress and can be cancelled there. const runRefine = async () => { if (!target || busy || refineBlocker) return const runTarget = target const runWorkflow = refineWorkflow const runRoles = refineRoles const runImage = refineImage const label = refineWorkflowLabel(runWorkflow) const promptId = createComfyExecutionId('comfy-prompt') const clientId = createComfyExecutionId('comfy-client') setRunning('refine') setStatus(null) registerJob({ id: promptId, projectId, projectName, page: 'workspace', targetId: 'retopo', kind: 'mesh', label: `Refine · ${label}` }) try { const meshRef = (await resolveSourceReference(projectId, 'mesh', runTarget.filePath || runTarget.filename)).sourceReference const scalarValues = {} for (const parameter of runWorkflow.parameters || []) { if (['image', 'video', 'mesh'].includes(getParameterValueType(parameter))) continue if ([runRoles.faces, runRoles.seed].includes(parameter)) continue if (Object.prototype.hasOwnProperty.call(refineValues, parameter.id)) { scalarValues[parameter.id] = coerceParameterValue(parameter, refineValues[parameter.id]) } } const seed = runRoles.seed ? (refine.randomSeed ? randomSeed() : Number(refineValues[runRoles.seed.id]) || 0) : null const inputs = { ...scalarValues, [runRoles.mesh.id]: { source: meshRef, name: runTarget.name }, [runRoles.image.id]: { source: runImage.sourceReference, name: runImage.name }, ...(runRoles.faces && refineFaces ? { [runRoles.faces.id]: snapToEnums(runRoles.faces, refineFaces) } : {}), ...(runRoles.seed ? { [runRoles.seed.id]: seed } : {}) } const assets = await runComfyWorkflow(projectId, { workflowId: runWorkflow.id, inputs, name: refinedName(runTarget), clientId, promptId, persistProcessingCard: false, detachedAsset: true, // A version of the mesh being refined, named explicitly rather than // inferred from the inputs. parentAssetId: runTarget.assetId, autoParentFromInputs: false }) const meshes = (Array.isArray(assets) ? assets : [assets]).filter(asset => asset && asset.id != null && asset.type === 'mesh') if (!meshes.length) throw new Error('The workflow finished without returning a mesh') let saved = meshes[0] if (saved.filename) { try { const thumbnail = await createMeshThumbnailFileFromUrl(assetUrl(saved.filename), String(saved.filename).split('/').pop() || 'mesh.glb') if (thumbnail) saved = { ...saved, ...(await uploadAssetThumbnail(saved.id, thumbnail)) } } catch (err) { console.warn('Could not render the refined mesh thumbnail', err) } } completeJob(promptId, { status: 'completed' }) const rows = [ { label: 'Workflow', value: label }, { label: 'Reference', value: runImage.name }, ...(runRoles.faces && refineFaces ? [{ label: 'Target faces', value: formatFaceCount(inputs[runRoles.faces.id]) }] : []), ...(seed != null ? [{ label: 'Seed', value: seed }] : []) ] await onResult({ asset: saved, module: 'retopo', label: `Refine · ${label}`, source: runTarget, params: { tool: 'refine', workflowId: runWorkflow.id, ...(runRoles.faces && refineFaces ? { faces: inputs[runRoles.faces.id] } : {}), ...(seed != null ? { seed } : {}), values: scalarValues, imageAssetId: runImage.assetId ?? null, summary: rows } }) setLastResult({ label: `Refine · ${label}`, rows, assetId: saved.id }) } catch (err) { if (err?.cancelled) { completeJob(promptId, { status: 'cancelled' }) } else { console.error('Workspace Refine failed', err) completeJob(promptId, { status: 'error', error: err.message }) setStatus({ tone: 'error', message: err.message || 'Refine failed.' }) addNotification({ title: 'Refine failed', message: err.message || 'ComfyUI workflow failed', tone: 'error', source: runWorkflow.name, projectId }) } } finally { setRunning(null) } } const useSelectedImage = selectedImageEntry ? () => imageImporters.fromEntry(selectedImageEntry) : null const renderRefine = () => ( <>