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:
+118
-44
@@ -363,6 +363,8 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
from app.config import settings as app_settings
|
||||
from app.models.cad_file import CadFile
|
||||
|
||||
from app.models.system_setting import SystemSetting as _SysSetting
|
||||
|
||||
sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
eng = create_engine(sync_url)
|
||||
with Session(eng) as session:
|
||||
@@ -386,8 +388,14 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
hex_color = entry.get("hex_color") or entry.get("color", "")
|
||||
if part_name and hex_color:
|
||||
color_map[part_name] = hex_color
|
||||
|
||||
settings_rows = session.execute(_select(_SysSetting)).scalars().all()
|
||||
sys_settings = {s.key: s.value for s in settings_rows}
|
||||
eng.dispose()
|
||||
|
||||
linear_deflection = float(sys_settings.get("gltf_preview_linear_deflection", "0.1"))
|
||||
angular_deflection = float(sys_settings.get("gltf_preview_angular_deflection", "0.5"))
|
||||
|
||||
step = _Path(step_path_str)
|
||||
if not step.exists():
|
||||
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
|
||||
@@ -411,7 +419,14 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(output_path),
|
||||
"--color_map", _json.dumps(color_map),
|
||||
"--linear_deflection", str(linear_deflection),
|
||||
"--angular_deflection", str(angular_deflection),
|
||||
]
|
||||
log_task_event(
|
||||
self.request.id,
|
||||
f"OCC tessellation: linear={linear_deflection}mm, angular={angular_deflection}rad",
|
||||
"info",
|
||||
)
|
||||
|
||||
try:
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
@@ -485,6 +500,7 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
|
||||
import json as _json
|
||||
import os as _os
|
||||
import subprocess as _subprocess
|
||||
import sys as _sys
|
||||
import uuid as _uuid
|
||||
from pathlib import Path as _Path
|
||||
|
||||
@@ -500,53 +516,97 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
|
||||
_sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
_eng = _ce(_sync_url)
|
||||
|
||||
# --- 1. Resolve geometry GLB path from existing gltf_geometry MediaAsset ---
|
||||
with _Session(_eng) as _sess:
|
||||
_row = _sess.execute(
|
||||
_sel(MediaAsset).where(
|
||||
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
|
||||
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
geom_glb_key = _row.storage_key if _row else None
|
||||
|
||||
if not geom_glb_key:
|
||||
# Trigger geometry generation first and retry this task
|
||||
log_task_event(self.request.id, "No gltf_geometry asset found — queuing geometry task first", "info")
|
||||
generate_gltf_geometry_task.delay(cad_file_id, product_id)
|
||||
raise self.retry(exc=RuntimeError("gltf_geometry not yet available"), countdown=30, max_retries=2)
|
||||
|
||||
geom_glb_path = _Path(app_settings.upload_dir) / geom_glb_key
|
||||
if not geom_glb_path.exists():
|
||||
raise RuntimeError(f"Geometry GLB not found on disk: {geom_glb_path}")
|
||||
|
||||
# --- 2. Resolve material map (SCHAEFFLER library names) ---
|
||||
from app.services.material_service import resolve_material_map
|
||||
|
||||
with _Session(_eng) as _sess:
|
||||
from app.models.cad_file import CadFile as _CF
|
||||
_cad = _sess.execute(_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))).scalar_one_or_none()
|
||||
raw_mat_map: dict = {}
|
||||
if _cad and _cad.cad_part_materials:
|
||||
raw_mat_map = _cad.cad_part_materials
|
||||
|
||||
mat_map = resolve_material_map(raw_mat_map)
|
||||
|
||||
# --- 3. Resolve asset library .blend path from system settings ---
|
||||
# --- 1. Resolve STEP file path and system settings ---
|
||||
from app.models.cad_file import CadFile as _CF
|
||||
from app.models.system_setting import SystemSetting
|
||||
with _Session(_eng) as _sess:
|
||||
_setting = _sess.execute(
|
||||
_sel(SystemSetting).where(SystemSetting.key == "asset_library_blend")
|
||||
).scalar_one_or_none()
|
||||
asset_library_blend = _setting.value if _setting and _setting.value else ""
|
||||
_eng.dispose()
|
||||
|
||||
# Output path next to geometry GLB
|
||||
output_path = geom_glb_path.parent / (geom_glb_path.stem.replace("_geometry", "") + "_production.glb")
|
||||
with _Session(_eng) as _sess:
|
||||
_cad = _sess.execute(
|
||||
_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))
|
||||
).scalar_one_or_none()
|
||||
step_path_str = _cad.stored_path if _cad else None
|
||||
|
||||
settings_rows = _sess.execute(_sel(SystemSetting)).scalars().all()
|
||||
sys_settings = {s.key: s.value for s in settings_rows}
|
||||
|
||||
if not step_path_str:
|
||||
raise RuntimeError(f"CadFile {cad_file_id} not found in DB")
|
||||
step_path = _Path(step_path_str)
|
||||
if not step_path.exists():
|
||||
raise RuntimeError(f"STEP file not found: {step_path}")
|
||||
|
||||
smooth_angle = float(sys_settings.get("blender_smooth_angle", "30"))
|
||||
prod_linear = float(sys_settings.get("gltf_production_linear_deflection", "0.03"))
|
||||
prod_angular = float(sys_settings.get("gltf_production_angular_deflection", "0.2"))
|
||||
|
||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
export_script = scripts_dir / "export_gltf.py"
|
||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||
if not occ_script.exists():
|
||||
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
|
||||
|
||||
prod_geom_glb = step_path.parent / f"{step_path.stem}_production_geom.glb"
|
||||
python_bin = _sys.executable
|
||||
occ_cmd = [
|
||||
python_bin, str(occ_script),
|
||||
"--step_path", str(step_path),
|
||||
"--output_path", str(prod_geom_glb),
|
||||
"--linear_deflection", str(prod_linear),
|
||||
"--angular_deflection", str(prod_angular),
|
||||
]
|
||||
log_task_event(
|
||||
self.request.id,
|
||||
f"Re-exporting STEP at production quality (linear={prod_linear}mm, angular={prod_angular}rad)",
|
||||
"info",
|
||||
)
|
||||
try:
|
||||
occ_result = _subprocess.run(occ_cmd, capture_output=True, text=True, timeout=180)
|
||||
for line in occ_result.stdout.splitlines():
|
||||
logger.info("[occ-prod] %s", line)
|
||||
if occ_result.returncode != 0 or not prod_geom_glb.exists() or prod_geom_glb.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"OCC export failed (exit {occ_result.returncode}): {occ_result.stderr[-500:]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
log_task_event(self.request.id, f"OCC re-export failed: {exc}", "error")
|
||||
raise self.retry(exc=exc, countdown=30)
|
||||
|
||||
geom_glb_path = prod_geom_glb
|
||||
|
||||
# --- 2. Resolve material map from Product.cad_part_materials (SCHAEFFLER library names) ---
|
||||
# cad_part_materials lives on Product (list[dict]), NOT on CadFile.
|
||||
# We look up the Product that owns this CadFile (prefer product_id arg if given).
|
||||
from app.services.material_service import resolve_material_map
|
||||
from app.domains.products.models import Product as _Product
|
||||
|
||||
with _Session(_eng) as _sess:
|
||||
_prod_query = _sel(_Product).where(_Product.cad_file_id == _uuid.UUID(cad_file_id))
|
||||
if product_id:
|
||||
_prod_query = _prod_query.where(_Product.id == _uuid.UUID(product_id))
|
||||
_product = _sess.execute(_prod_query).scalars().first()
|
||||
raw_materials: list[dict] = _product.cad_part_materials if _product else []
|
||||
|
||||
# Convert list[{"part_name": X, "material": Y}] → dict[str, str] for resolve_material_map
|
||||
raw_mat_map: dict[str, str] = {
|
||||
m["part_name"]: m["material"]
|
||||
for m in raw_materials
|
||||
if m.get("part_name") and m.get("material")
|
||||
}
|
||||
mat_map = resolve_material_map(raw_mat_map)
|
||||
logger.info(
|
||||
"generate_gltf_production_task: resolved %d material(s) for cad %s (product: %s)",
|
||||
len(mat_map), cad_file_id, _product.id if _product else "none",
|
||||
)
|
||||
|
||||
# --- 3. Run Blender: apply materials + smooth shading + export production GLB ---
|
||||
# Use get_material_library_path() which checks active AssetLibrary first,
|
||||
# then falls back to the legacy material_library_path system setting.
|
||||
from app.services.template_service import get_material_library_path
|
||||
asset_library_blend = get_material_library_path() or ""
|
||||
_eng.dispose()
|
||||
|
||||
output_path = step_path.parent / f"{step_path.stem}_production.glb"
|
||||
|
||||
export_script = scripts_dir / "export_gltf.py"
|
||||
if not is_blender_available():
|
||||
raise RuntimeError("Blender is not available — cannot generate production GLB")
|
||||
if not export_script.exists():
|
||||
@@ -560,13 +620,20 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
|
||||
"--glb_path", str(geom_glb_path),
|
||||
"--output_path", str(output_path),
|
||||
"--material_map", _json.dumps(mat_map),
|
||||
"--smooth_angle", str(smooth_angle),
|
||||
]
|
||||
if asset_library_blend:
|
||||
cmd += ["--asset_library_blend", asset_library_blend]
|
||||
|
||||
log_task_event(self.request.id, f"Running Blender export_gltf.py for {geom_glb_path.name}", "info")
|
||||
log_task_event(
|
||||
self.request.id,
|
||||
f"Running Blender export_gltf.py — {len(mat_map)} material(s), smooth={smooth_angle}°",
|
||||
"info",
|
||||
)
|
||||
try:
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
for line in result.stdout.splitlines():
|
||||
logger.info("[export-gltf] %s", line)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
@@ -575,6 +642,12 @@ def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None
|
||||
log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error")
|
||||
logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc)
|
||||
raise self.retry(exc=exc, countdown=30)
|
||||
finally:
|
||||
# Clean up the high-quality temp geometry GLB (not needed after Blender export)
|
||||
try:
|
||||
prod_geom_glb.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
|
||||
|
||||
@@ -888,7 +961,7 @@ def render_order_line_task(self, order_line_id: str):
|
||||
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
|
||||
else:
|
||||
# ── Still image path ────────────────────────────────────────
|
||||
emit(order_line_id, f"Calling renderer (STEP → STL → still) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
|
||||
emit(order_line_id, f"Calling renderer (STEP → GLB → Blender) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
|
||||
from app.services.step_processor import render_to_file
|
||||
|
||||
success, render_log = render_to_file(
|
||||
@@ -912,6 +985,7 @@ def render_order_line_task(self, order_line_id: str):
|
||||
rotation_y=rotation_y,
|
||||
rotation_z=rotation_z,
|
||||
job_id=order_line_id,
|
||||
order_line_id=order_line_id,
|
||||
noise_threshold=noise_threshold,
|
||||
denoiser=denoiser,
|
||||
denoising_input_passes=denoising_input_passes,
|
||||
|
||||
Reference in New Issue
Block a user