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:
2026-03-06 20:56:26 +01:00
parent 7a1329958d
commit a18d4c23ec
14 changed files with 922 additions and 10 deletions
+60
View File
@@ -0,0 +1,60 @@
import api from './client'
export interface AssetLibraryCatalog {
materials: string[]
node_groups: string[]
}
export interface AssetLibrary {
id: string
name: string
description: string | null
original_filename: string | null
catalog: AssetLibraryCatalog
is_active: boolean
created_at: string
updated_at: string
}
export async function listAssetLibraries(): Promise<AssetLibrary[]> {
const { data } = await api.get<AssetLibrary[]>('/asset-libraries')
return data
}
export async function getAssetLibrary(id: string): Promise<AssetLibrary> {
const { data } = await api.get<AssetLibrary>(`/asset-libraries/${id}`)
return data
}
export async function createAssetLibrary(params: {
name: string
description?: string
blend_file: File
}): Promise<AssetLibrary> {
const form = new FormData()
form.append('name', params.name)
if (params.description) form.append('description', params.description)
form.append('blend_file', params.blend_file)
const { data } = await api.post<AssetLibrary>('/asset-libraries', form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function uploadAssetLibraryBlend(id: string, file: File): Promise<AssetLibrary> {
const form = new FormData()
form.append('blend_file', file)
const { data } = await api.post<AssetLibrary>(`/asset-libraries/${id}/upload-blend`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return data
}
export async function refreshAssetLibraryCatalog(id: string): Promise<AssetLibrary> {
const { data } = await api.post<AssetLibrary>(`/asset-libraries/${id}/refresh-catalog`)
return data
}
export async function deleteAssetLibrary(id: string): Promise<void> {
await api.delete(`/asset-libraries/${id}`)
}
+205
View File
@@ -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>
)
}