feat(K): Blender Asset Library + production exports (GLB + .blend)
- feat(migration): 045_asset_libraries — new asset_libraries table (blend_file_path, catalog JSONB) - feat(model): AssetLibrary SQLAlchemy model in domains/materials/models.py - feat(api): POST/GET/PATCH/DELETE /api/asset-libraries + /upload-blend + /refresh-catalog endpoints - feat(celery): refresh_asset_library_catalog task on thumbnail_rendering queue — runs Blender headless - feat(blender): catalog_assets.py — extracts asset-marked materials + node_groups from .blend - feat(blender): asset_library.py — apply_asset_library_materials + apply_asset_library_node_groups helpers - feat(blender): export_gltf.py — STEP→STL→GLB production export with optional asset library - feat(blender): export_blend.py — STEP→STL→.blend production export with pack_all() - feat(frontend): api/assetLibraries.ts — full CRUD API client - feat(frontend): AssetLibraryPanel in Admin.tsx — upload, refresh, expand catalog view - docs: Blender asset_data marking requirement learning in LEARNINGS.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,10 @@ import { getMaterialLibraryInfo, uploadMaterialLibrary, deleteMaterialLibrary }
|
||||
import type { MaterialLibraryInfo } from '../api/renderTemplates'
|
||||
import { listPricingTiers } from '../api/pricing'
|
||||
import { listOutputTypes } from '../api/outputTypes'
|
||||
import {
|
||||
listAssetLibraries, createAssetLibrary, deleteAssetLibrary, refreshAssetLibraryCatalog,
|
||||
type AssetLibrary,
|
||||
} from '../api/assetLibraries'
|
||||
|
||||
export default function AdminPage() {
|
||||
const qc = useQueryClient()
|
||||
@@ -218,6 +222,11 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Asset Libraries */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<AssetLibraryPanel />
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Users (admin only) */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
@@ -1041,3 +1050,199 @@ function PricingSummaryCard() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ── Asset Library Panel ───────────────────────────────────────────────────────
|
||||
|
||||
function AssetLibraryPanel() {
|
||||
const qc = useQueryClient()
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [newFile, setNewFile] = useState<File | null>(null)
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: libraries = [] } = useQuery({
|
||||
queryKey: ['asset-libraries'],
|
||||
queryFn: listAssetLibraries,
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createAssetLibrary({ name: newName, description: newDesc || undefined, blend_file: newFile! }),
|
||||
onSuccess: () => {
|
||||
toast.success('Asset library created')
|
||||
qc.invalidateQueries({ queryKey: ['asset-libraries'] })
|
||||
setShowCreate(false)
|
||||
setNewName('')
|
||||
setNewDesc('')
|
||||
setNewFile(null)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteAssetLibrary(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Asset library deleted')
|
||||
qc.invalidateQueries({ queryKey: ['asset-libraries'] })
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const refreshMut = useMutation({
|
||||
mutationFn: (id: string) => refreshAssetLibraryCatalog(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Catalog refresh queued')
|
||||
setTimeout(() => qc.invalidateQueries({ queryKey: ['asset-libraries'] }), 3000)
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to refresh'),
|
||||
})
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="p-4 border-b border-border-light flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={16} className="text-content-muted" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">Asset Libraries</h2>
|
||||
<p className="text-xs text-content-muted mt-0.5">
|
||||
Upload Blender .blend files containing production materials and node groups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
|
||||
<Plus size={14} />New Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className="p-4 border-b border-border-light bg-surface-alt space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Library name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="Description (optional)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="btn-secondary cursor-pointer">
|
||||
<Upload size={14} />
|
||||
{newFile ? newFile.name : 'Choose .blend file'}
|
||||
<input
|
||||
type="file"
|
||||
accept=".blend"
|
||||
className="hidden"
|
||||
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn-primary"
|
||||
disabled={!newName || !newFile || createMut.isPending}
|
||||
onClick={() => createMut.mutate()}
|
||||
>
|
||||
{createMut.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => setShowCreate(false)}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{libraries.length === 0 ? (
|
||||
<div className="p-8 text-center text-content-muted text-sm">
|
||||
No asset libraries yet. Upload a .blend file to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border-light">
|
||||
{(libraries as AssetLibrary[]).map((lib) => {
|
||||
const isExpanded = expanded.has(lib.id)
|
||||
const matCount = lib.catalog.materials.length
|
||||
const ngCount = lib.catalog.node_groups.length
|
||||
return (
|
||||
<div key={lib.id}>
|
||||
<div className="p-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => toggle(lib.id)}
|
||||
className="text-content-muted hover:text-content"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-content text-sm">{lib.name}</p>
|
||||
{lib.description && (
|
||||
<p className="text-xs text-content-muted">{lib.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-content-muted">
|
||||
{lib.original_filename ?? '—'}
|
||||
</span>
|
||||
<span className="badge-neutral text-xs">{matCount} materials</span>
|
||||
<span className="badge-neutral text-xs">{ngCount} node groups</span>
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => refreshMut.mutate(lib.id)}
|
||||
disabled={refreshMut.isPending}
|
||||
title="Re-scan catalog from .blend"
|
||||
>
|
||||
<RefreshCw size={12} />Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn-danger text-xs"
|
||||
onClick={() => { if (confirm(`Delete "${lib.name}"?`)) deleteMut.mutate(lib.id) }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-10 pb-4 space-y-3">
|
||||
{matCount > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-content-muted mb-1">Materials</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{lib.catalog.materials.map((m) => (
|
||||
<span key={m} className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ngCount > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-content-muted mb-1">Node Groups</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{lib.catalog.node_groups.map((ng) => (
|
||||
<span key={ng} className="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{ng}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{matCount === 0 && ngCount === 0 && (
|
||||
<p className="text-xs text-content-muted italic">
|
||||
No assets found. Click "Refresh" to scan the .blend for marked assets.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user