feat(O): UI-Vollständigkeit + v3-Workflows + OCC-Kantenanalyse
Backend:
- Phase I: notification_configs router (GET/PUT/{event}/{channel}/POST reset)
war bereits in notifications.py — add-alias endpoint in uploads.py ergänzt
- OutputType schema: workflow_definition_id + workflow_name fields;
PATCH unterstützt Workflow-Zuweisung; _enrich_workflow_names() batch query
- Dispatch-Integration: orders.py dispatch_renders() → dispatch_render_with_workflow()
mit Legacy-Fallback; neues Logging
- uploads.py: POST /validations/{id}/add-alias für Material-Lücken
Pipeline:
- step_processor.py: extract_mesh_edge_data() via OCC — berechnet Dihedralwinkel
aller Kanten, liefert suggested_smooth_angle + sharp_edge_midpoints
Integriert in extract_cad_metadata() und process_cad_file()
- domains/rendering/tasks.py: apply_asset_library_materials_task (K3),
export_gltf_for_order_line_task → Blender export_gltf.py (K4),
export_blend_for_order_line_task → export_blend.py fix (K5)
- render-worker/scripts/still_render.py: _mark_sharp_and_seams() mit
OCC midpoint KD-tree matching + UV-Seam-Markierung
- render-worker/scripts/blender_render.py: identische Funktion + mesh_attributes parsing
Frontend:
- Layout.tsx: Upload-Link in Sidebar (alle User); Asset Libraries Link (admin/PM)
- App.tsx: /asset-libraries Route
- AssetLibrary.tsx: neue Seite (Upload, Catalog-Anzeige, Refresh, Toggle, Delete)
- OutputTypeTable.tsx: Workflow-Dropdown + Legacy/Workflow Badge
- ProductDetail.tsx: Geometry-Karte (Volumen, Surface, BBox, Sharp-Winkel)
- api/outputTypes.ts + api/products.ts: neue Felder
- api/imports.ts: ImportValidation API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ class OutputTypePatch(BaseModel):
|
||||
transparent_bg: bool | None = None
|
||||
pricing_tier_id: int | None = None
|
||||
cycles_device: str | None = None
|
||||
workflow_definition_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class OutputTypeOut(BaseModel):
|
||||
@@ -51,6 +52,8 @@ class OutputTypeOut(BaseModel):
|
||||
pricing_tier_id: int | None = None
|
||||
pricing_tier_name: str | None = None
|
||||
price_per_item: float | None = None
|
||||
workflow_definition_id: uuid.UUID | None = None
|
||||
workflow_name: str | None = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -355,11 +355,16 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
|
||||
max_retries=1,
|
||||
)
|
||||
def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
"""Export a geometry-only GLB from the STL cache using trimesh (no Blender).
|
||||
"""Export a GLB from the STL cache via Blender subprocess (with trimesh fallback).
|
||||
|
||||
Publishes a MediaAsset with asset_type='gltf_geometry'.
|
||||
Publishes a MediaAsset with asset_type='gltf_geometry' (no asset lib) or
|
||||
'gltf_production' (when an asset library is applied).
|
||||
Requires the STL low-quality cache to exist.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
|
||||
if not step_path_str:
|
||||
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
||||
@@ -372,14 +377,47 @@ def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
)
|
||||
|
||||
output_path = step.parent / f"{step.stem}_geometry.glb"
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
export_script = scripts_dir / "export_gltf.py"
|
||||
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
|
||||
asset_type = "gltf_geometry"
|
||||
|
||||
if is_blender_available() and export_script.exists():
|
||||
blender_bin = find_blender()
|
||||
cmd = [
|
||||
blender_bin, "--background",
|
||||
"--python", str(export_script),
|
||||
"--",
|
||||
"--stl_path", str(stl_path),
|
||||
"--output_path", str(output_path),
|
||||
"--asset_library_blend", "",
|
||||
"--material_map", json.dumps({}),
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
)
|
||||
publish_asset.delay(order_line_id, asset_type, str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name)
|
||||
return {"glb_path": str(output_path), "method": "blender"}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Blender GLB export failed for %s, falling back to trimesh: %s",
|
||||
order_line_id, exc,
|
||||
)
|
||||
|
||||
# Trimesh fallback
|
||||
try:
|
||||
import trimesh
|
||||
mesh = trimesh.load(str(stl_path))
|
||||
mesh.export(str(output_path))
|
||||
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed: %s", output_path.name)
|
||||
return {"glb_path": str(output_path)}
|
||||
publish_asset.delay(order_line_id, asset_type, str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed via trimesh: %s", output_path.name)
|
||||
return {"glb_path": str(output_path), "method": "trimesh"}
|
||||
except Exception as exc:
|
||||
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
|
||||
raise self.retry(exc=exc, countdown=15)
|
||||
@@ -392,11 +430,12 @@ def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
max_retries=1,
|
||||
)
|
||||
def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
"""Export a production-quality GLB via Blender + asset library (export_gltf.py).
|
||||
"""Export a production .blend file via Blender + asset library (export_blend.py).
|
||||
|
||||
Publishes a MediaAsset with asset_type='blend_production'.
|
||||
Requires Blender + the render-scripts directory.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
@@ -409,15 +448,38 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
if not stl_path.exists():
|
||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
||||
|
||||
output_path = step.parent / f"{step.stem}_production.glb"
|
||||
output_path = step.parent / f"{step.stem}_production.blend"
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
export_script = scripts_dir / "export_gltf.py"
|
||||
export_script = scripts_dir / "export_blend.py"
|
||||
|
||||
from app.services.render_blender import find_blender
|
||||
blender_bin = find_blender()
|
||||
if not blender_bin:
|
||||
raise RuntimeError("Blender binary not found — cannot run export_blend task")
|
||||
|
||||
# Resolve asset library path and material map from DB
|
||||
asset_lib_path = ""
|
||||
mat_map: dict = {}
|
||||
try:
|
||||
from sqlalchemy import create_engine, select as sql_select
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import settings as app_settings
|
||||
from app.domains.orders.models import OrderLine
|
||||
from app.domains.products.models import Product
|
||||
|
||||
engine = create_engine(app_settings.database_url_sync)
|
||||
with Session(engine) as s:
|
||||
line = s.execute(sql_select(OrderLine).where(OrderLine.id == order_line_id)).scalar_one_or_none()
|
||||
if line:
|
||||
product = s.execute(sql_select(Product).where(Product.id == line.product_id)).scalar_one_or_none()
|
||||
if product:
|
||||
mat_map = {
|
||||
m.get("part_name", ""): m.get("material", "")
|
||||
for m in (product.cad_part_materials or [])
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("export_blend_for_order_line_task: DB resolution error (non-fatal): %s", exc)
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
blender_bin, "--background",
|
||||
@@ -425,20 +487,101 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
"--",
|
||||
"--stl_path", str(stl_path),
|
||||
"--output_path", str(output_path),
|
||||
"--asset_library_blend", asset_lib_path,
|
||||
"--material_map", json.dumps(mat_map),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
f"export_blend.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
)
|
||||
publish_asset.delay(order_line_id, "blend_production", str(output_path))
|
||||
logger.info("export_blend_for_order_line_task completed: %s", output_path.name)
|
||||
return {"glb_path": str(output_path)}
|
||||
return {"blend_path": str(output_path)}
|
||||
except Exception as exc:
|
||||
logger.error("export_blend_for_order_line_task failed for %s: %s", order_line_id, exc)
|
||||
raise self.retry(exc=exc, countdown=30)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.domains.rendering.tasks.apply_asset_library_materials_task",
|
||||
queue="thumbnail_rendering",
|
||||
max_retries=1,
|
||||
)
|
||||
def apply_asset_library_materials_task(self, order_line_id: str, asset_library_id: str) -> dict:
|
||||
"""Apply Blender asset library materials to a render via the asset_library.py script."""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from app.services.render_blender import find_blender
|
||||
|
||||
blender_bin = find_blender()
|
||||
if not blender_bin:
|
||||
raise RuntimeError("Blender not available")
|
||||
|
||||
# Resolve paths from DB
|
||||
def _inner():
|
||||
from sqlalchemy import create_engine, select as sql_select
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import settings
|
||||
from app.domains.orders.models import OrderLine
|
||||
from app.domains.products.models import CadFile, Product
|
||||
|
||||
engine = create_engine(settings.database_url_sync)
|
||||
with Session(engine) as s:
|
||||
line = s.execute(sql_select(OrderLine).where(OrderLine.id == order_line_id)).scalar_one_or_none()
|
||||
if not line:
|
||||
return None, None, None
|
||||
product = s.execute(sql_select(Product).where(Product.id == line.product_id)).scalar_one_or_none()
|
||||
if not product or not product.cad_file_id:
|
||||
return None, None, None
|
||||
cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none()
|
||||
stl_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_low.stl") if cad else None
|
||||
|
||||
# Resolve asset library blend path
|
||||
try:
|
||||
from app.domains.materials.models import AssetLibrary
|
||||
lib = s.execute(sql_select(AssetLibrary).where(AssetLibrary.id == asset_library_id)).scalar_one_or_none()
|
||||
blend_path = lib.blend_file_path if lib else None
|
||||
except Exception:
|
||||
blend_path = None
|
||||
|
||||
mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])}
|
||||
return stl_path, blend_path, mat_map
|
||||
|
||||
result = _inner()
|
||||
if result is None or result[0] is None:
|
||||
logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id)
|
||||
return {"status": "skipped"}
|
||||
|
||||
stl_path, blend_path, mat_map = result
|
||||
if not stl_path or not Path(stl_path).exists():
|
||||
logger.warning("STL not found for %s", order_line_id)
|
||||
return {"status": "skipped", "reason": "stl_not_found"}
|
||||
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script = scripts_dir / "asset_library.py"
|
||||
|
||||
cmd = [
|
||||
blender_bin, "--background", "--python", str(script), "--",
|
||||
"--stl_path", stl_path,
|
||||
"--asset_library_blend", blend_path or "",
|
||||
"--material_map", json.dumps(mat_map),
|
||||
]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"asset_library.py failed: {proc.stderr[-500:]}")
|
||||
except Exception as exc:
|
||||
logger.error("apply_asset_library_materials_task failed for %s: %s", order_line_id, exc)
|
||||
raise self.retry(exc=exc, countdown=15)
|
||||
|
||||
return {"status": "applied", "order_line_id": order_line_id}
|
||||
|
||||
|
||||
def _build_ffmpeg_cmd(
|
||||
frames_dir: Path, output_mp4: Path, fps: int = 30, bg_color: str = ""
|
||||
) -> list:
|
||||
|
||||
Reference in New Issue
Block a user