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:
@@ -0,0 +1,77 @@
|
||||
"""Asset library helpers for Blender render scripts.
|
||||
|
||||
Provides functions to link materials and node groups from a .blend asset library
|
||||
into the current scene, and apply them to mesh objects.
|
||||
|
||||
These functions are intended to be imported by still_render.py / turntable_render.py
|
||||
when a RenderTemplate has an asset library associated.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def apply_asset_library_materials(blend_path: str, material_map: dict) -> None:
|
||||
"""Link materials from an asset library .blend and apply them to mesh slots.
|
||||
|
||||
Args:
|
||||
blend_path: Absolute path to the .blend library file.
|
||||
material_map: Mapping of current slot material name -> library material name.
|
||||
E.g. {"Steel--Stahl": "SCHAEFFLER_010101_Steel-Bare"}
|
||||
"""
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
if not material_map:
|
||||
return
|
||||
|
||||
target_names = set(material_map.values())
|
||||
|
||||
# Link materials from the library
|
||||
with bpy.data.libraries.load(blend_path, link=True, assets_only=True) as (src, dst):
|
||||
dst.materials = [name for name in src.materials if name in target_names]
|
||||
|
||||
linked = {m.name for m in dst.materials if m is not None}
|
||||
logger.info("Linked %d materials from %s", len(linked), blend_path)
|
||||
|
||||
# Apply to all mesh object material slots
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
for slot in obj.material_slots:
|
||||
if slot.material is None:
|
||||
continue
|
||||
resolved = material_map.get(slot.material.name)
|
||||
if resolved and resolved in bpy.data.materials:
|
||||
slot.material = bpy.data.materials[resolved]
|
||||
|
||||
|
||||
def apply_asset_library_node_groups(blend_path: str, modifier_map: dict) -> None:
|
||||
"""Link geometry node groups from an asset library and apply as modifiers.
|
||||
|
||||
Args:
|
||||
blend_path: Absolute path to the .blend library file.
|
||||
modifier_map: Mapping of object name substring -> node group name.
|
||||
E.g. {"ring": "WearPattern_GeoNodes"}
|
||||
"""
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
if not modifier_map:
|
||||
return
|
||||
|
||||
target_names = set(modifier_map.values())
|
||||
|
||||
with bpy.data.libraries.load(blend_path, link=True, assets_only=True) as (src, dst):
|
||||
dst.node_groups = [name for name in src.node_groups if name in target_names]
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
for part_substr, ng_name in modifier_map.items():
|
||||
if part_substr.lower() in obj.name.lower():
|
||||
ng = bpy.data.node_groups.get(ng_name)
|
||||
if ng:
|
||||
mod = obj.modifiers.new(name=ng_name, type="NODES")
|
||||
mod.node_group = ng
|
||||
logger.info("Applied node group %s to %s", ng_name, obj.name)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Blender headless script: extract asset catalog from a .blend library file.
|
||||
|
||||
Usage:
|
||||
blender --background --python catalog_assets.py -- <blend_path>
|
||||
|
||||
Outputs a single JSON line to stdout:
|
||||
{"materials": ["Mat1", "Mat2", ...], "node_groups": ["NG1", ...]}
|
||||
|
||||
Only assets marked via Blender's asset system (asset_data is not None) are included.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
|
||||
def main() -> None:
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print(json.dumps({"error": "No blend path provided after --"}))
|
||||
sys.exit(1)
|
||||
|
||||
blend_path = argv[argv.index("--") + 1]
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
bpy.ops.wm.open_mainfile(filepath=blend_path)
|
||||
|
||||
materials = [m.name for m in bpy.data.materials if m.asset_data is not None]
|
||||
node_groups = [ng.name for ng in bpy.data.node_groups if ng.asset_data is not None]
|
||||
|
||||
catalog = {"materials": materials, "node_groups": node_groups}
|
||||
print(json.dumps(catalog))
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Blender headless script: export a STEP-derived scene as a production .blend.
|
||||
|
||||
Usage:
|
||||
blender --background --python export_blend.py -- \\
|
||||
--stl_path /path/to/file.stl \\
|
||||
--output_path /path/to/output.blend \\
|
||||
[--asset_library_blend /path/to/library.blend] \\
|
||||
[--material_map '{"SrcMat": "LibMat"}']
|
||||
|
||||
The script:
|
||||
1. Imports the STL file (with mm→m scale).
|
||||
2. Optionally applies asset library materials from a .blend.
|
||||
3. Packs all external data.
|
||||
4. Saves a copy as the output .blend.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("No arguments after --", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
rest = argv[argv.index("--") + 1:]
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--stl_path", required=True)
|
||||
parser.add_argument("--output_path", required=True)
|
||||
parser.add_argument("--asset_library_blend", default=None)
|
||||
parser.add_argument("--material_map", default="{}")
|
||||
return parser.parse_args(rest)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
material_map: dict = json.loads(args.material_map)
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
# Clean scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import STL
|
||||
bpy.ops.import_mesh.stl(filepath=args.stl_path)
|
||||
|
||||
# Scale mm → m
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.scale = (0.001, 0.001, 0.001)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
|
||||
# Apply asset library materials if provided
|
||||
if args.asset_library_blend and material_map:
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from asset_library import apply_asset_library_materials
|
||||
apply_asset_library_materials(args.asset_library_blend, material_map)
|
||||
|
||||
# Pack all external data into the .blend
|
||||
bpy.ops.file.pack_all()
|
||||
|
||||
# Save a copy to output_path
|
||||
bpy.ops.wm.save_as_mainfile(filepath=args.output_path, compress=True, copy=True)
|
||||
|
||||
print(f".blend exported to {args.output_path}")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Blender headless script: export a STEP-derived scene as a production GLB.
|
||||
|
||||
Usage:
|
||||
blender --background --python export_gltf.py -- \\
|
||||
--stl_path /path/to/file.stl \\
|
||||
--output_path /path/to/output.glb \\
|
||||
[--asset_library_blend /path/to/library.blend] \\
|
||||
[--material_map '{"SrcMat": "LibMat"}']
|
||||
|
||||
The script:
|
||||
1. Imports the STL file (with mm→m scale).
|
||||
2. Optionally applies asset library materials from a .blend.
|
||||
3. Exports as GLB (Draco-compressed if available, otherwise standard).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
argv = sys.argv
|
||||
if "--" not in argv:
|
||||
print("No arguments after --", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
rest = argv[argv.index("--") + 1:]
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--stl_path", required=True)
|
||||
parser.add_argument("--output_path", required=True)
|
||||
parser.add_argument("--asset_library_blend", default=None)
|
||||
parser.add_argument("--material_map", default="{}")
|
||||
return parser.parse_args(rest)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
material_map: dict = json.loads(args.material_map)
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
|
||||
# Clean scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import STL
|
||||
bpy.ops.import_mesh.stl(filepath=args.stl_path)
|
||||
|
||||
# Scale mm → m
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.scale = (0.001, 0.001, 0.001)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
|
||||
# Apply asset library materials if provided
|
||||
if args.asset_library_blend and material_map:
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from asset_library import apply_asset_library_materials
|
||||
apply_asset_library_materials(args.asset_library_blend, material_map)
|
||||
|
||||
# Export GLB
|
||||
try:
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=args.output_path,
|
||||
export_format="GLB",
|
||||
export_apply=True,
|
||||
use_selection=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"GLB export failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"GLB exported to {args.output_path}")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user