feat: GPU rendering + material matching + perf improvements

- GPU: fix Cycles device activation order — set compute_device_type
  BEFORE engine init, re-set AFTER open_mainfile wipes preferences
- GPU: remove _mark_sharp_and_seams edit-mode loop (redundant with
  Blender 5.0 shade_smooth_by_angle), saves ~200s/render on 175 parts
- Material: fix _AFN suffix mismatch — build AF-stripped mat_map keys
  and add prefix fallback in _apply_material_library (blender_render.py)
- Material: production GLB now uses get_material_library_path() which
  checks active AssetLibrary instead of empty legacy system setting
- Admin: RenderTemplateTable multi-select output types (M2M frontend)
- Admin: MaterialLibraryPanel replaced with link to Asset Libraries
- UX: move Toaster to top-left to avoid dispatch button overlap
- SQLAlchemy: add .unique() to all RenderTemplate M2M collection queries
- Logging: flush=True on all Blender progress prints, stdout reconfigure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 19:05:03 +01:00
parent 934728da77
commit ee6eb34b4c
34 changed files with 1274 additions and 511 deletions
+67
View File
@@ -41,6 +41,11 @@ SETTINGS_DEFAULTS: dict[str, str] = {
"smtp_user": "",
"smtp_password": "",
"smtp_from_address": "",
# glTF tessellation quality (OCC BRepMesh)
"gltf_preview_linear_deflection": "0.1", # mm — geometry GLB for viewer
"gltf_preview_angular_deflection": "0.5", # rad
"gltf_production_linear_deflection": "0.03", # mm — production GLB
"gltf_production_angular_deflection": "0.2", # rad
# 3D viewer / glTF export settings
"gltf_scale_factor": "0.001",
"gltf_smooth_normals": "true",
@@ -71,6 +76,10 @@ class SettingsOut(BaseModel):
smtp_user: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
gltf_preview_linear_deflection: float = 0.1
gltf_preview_angular_deflection: float = 0.5
gltf_production_linear_deflection: float = 0.03
gltf_production_angular_deflection: float = 0.2
gltf_scale_factor: float = 0.001
gltf_smooth_normals: bool = True
viewer_max_distance: float = 50.0
@@ -99,6 +108,10 @@ class SettingsUpdate(BaseModel):
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from_address: str | None = None
gltf_preview_linear_deflection: float | None = None
gltf_preview_angular_deflection: float | None = None
gltf_production_linear_deflection: float | None = None
gltf_production_angular_deflection: float | None = None
gltf_scale_factor: float | None = None
gltf_smooth_normals: bool | None = None
viewer_max_distance: float | None = None
@@ -213,6 +226,10 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
smtp_user=raw.get("smtp_user", ""),
smtp_password=raw.get("smtp_password", ""),
smtp_from_address=raw.get("smtp_from_address", ""),
gltf_preview_linear_deflection=float(raw.get("gltf_preview_linear_deflection", "0.1")),
gltf_preview_angular_deflection=float(raw.get("gltf_preview_angular_deflection", "0.5")),
gltf_production_linear_deflection=float(raw.get("gltf_production_linear_deflection", "0.03")),
gltf_production_angular_deflection=float(raw.get("gltf_production_angular_deflection", "0.2")),
gltf_scale_factor=float(raw.get("gltf_scale_factor", "0.001")),
gltf_smooth_normals=raw.get("gltf_smooth_normals", "true") == "true",
viewer_max_distance=float(raw.get("viewer_max_distance", "50")),
@@ -328,6 +345,22 @@ async def update_settings(
updates["gltf_pbr_roughness"] = str(body.gltf_pbr_roughness)
if body.gltf_pbr_metallic is not None:
updates["gltf_pbr_metallic"] = str(body.gltf_pbr_metallic)
if body.gltf_preview_linear_deflection is not None:
if not (0.001 <= body.gltf_preview_linear_deflection <= 10.0):
raise HTTPException(400, detail="gltf_preview_linear_deflection must be 0.00110.0 mm")
updates["gltf_preview_linear_deflection"] = str(body.gltf_preview_linear_deflection)
if body.gltf_preview_angular_deflection is not None:
if not (0.05 <= body.gltf_preview_angular_deflection <= 1.5):
raise HTTPException(400, detail="gltf_preview_angular_deflection must be 0.051.5 rad")
updates["gltf_preview_angular_deflection"] = str(body.gltf_preview_angular_deflection)
if body.gltf_production_linear_deflection is not None:
if not (0.001 <= body.gltf_production_linear_deflection <= 10.0):
raise HTTPException(400, detail="gltf_production_linear_deflection must be 0.00110.0 mm")
updates["gltf_production_linear_deflection"] = str(body.gltf_production_linear_deflection)
if body.gltf_production_angular_deflection is not None:
if not (0.05 <= body.gltf_production_angular_deflection <= 1.5):
raise HTTPException(400, detail="gltf_production_angular_deflection must be 0.051.5 rad")
updates["gltf_production_angular_deflection"] = str(body.gltf_production_angular_deflection)
for k, v in updates.items():
await _save_setting(db, k, v)
@@ -470,6 +503,40 @@ async def generate_missing_geometry_glbs(
return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"}
@router.post("/settings/recover-stuck-processing", status_code=status.HTTP_200_OK)
async def recover_stuck_processing(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Reset CAD files stuck in 'processing' for more than 10 minutes to 'failed'.
Call this when a CAD file shows 'processing' indefinitely. The auto-recovery
beat task also runs every 5 minutes, so this is just for immediate relief.
"""
from datetime import datetime, timedelta
from sqlalchemy import update as sql_update, and_
cutoff = datetime.utcnow() - timedelta(minutes=10)
result = await db.execute(
sql_update(CadFile)
.where(
and_(
CadFile.processing_status == ProcessingStatus.processing,
CadFile.updated_at < cutoff,
)
)
.values(
processing_status=ProcessingStatus.failed,
error_message="Processing timed out — worker may have crashed. Use 'Regenerate Thumbnail' to retry.",
)
.returning(CadFile.id)
)
reset_ids = [str(r[0]) for r in result.fetchall()]
await db.commit()
return {"reset": len(reset_ids), "ids": reset_ids,
"message": f"Reset {len(reset_ids)} stuck file(s) to 'failed'"}
@router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
async def seed_workflows(
admin: User = Depends(require_admin),