refactor: replace STL intermediary with OCC-native STEP→GLB pipeline
- export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method) - Blender scripts (blender_render.py, still_render.py, turntable_render.py, export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m - step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache, replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB after thumbnail render - render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step, remove convert_step_to_stl and export_per_part_stls - domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks to use GLB instead of STL - cad.py: remove STL download/generate endpoints, add generate-gltf-production - admin.py: generate-missing-stls → generate-missing-geometry-glbs - Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -438,30 +438,36 @@ async def reextract_all_metadata(
|
||||
return {"queued": queued, "message": f"Queued {queued} CAD file(s) for metadata re-extraction"}
|
||||
|
||||
|
||||
@router.post("/settings/generate-missing-stls", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_missing_stls(
|
||||
@router.post("/settings/generate-missing-geometry-glbs", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_missing_geometry_glbs(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue STL generation for every quality missing from each completed CAD file."""
|
||||
from pathlib import Path as _Path
|
||||
"""Queue geometry GLB generation for every completed CAD file that has no gltf_geometry MediaAsset."""
|
||||
import uuid as _uuid
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
|
||||
result = await db.execute(
|
||||
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
|
||||
)
|
||||
cad_files = result.scalars().all()
|
||||
|
||||
from app.tasks.step_tasks import generate_stl_cache
|
||||
# Bulk-fetch existing gltf_geometry assets
|
||||
existing_result = await db.execute(
|
||||
select(MediaAsset.cad_file_id).where(MediaAsset.asset_type == MediaAssetType.gltf_geometry)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing_result.all()}
|
||||
|
||||
from app.tasks.step_tasks import generate_gltf_geometry_task
|
||||
queued = 0
|
||||
for cad_file in cad_files:
|
||||
if not cad_file.stored_path:
|
||||
continue
|
||||
step = _Path(cad_file.stored_path)
|
||||
for quality in ("low", "high"):
|
||||
if not (step.parent / f"{step.stem}_{quality}.stl").exists():
|
||||
generate_stl_cache.delay(str(cad_file.id), quality)
|
||||
queued += 1
|
||||
if cad_file.id not in existing_ids:
|
||||
generate_gltf_geometry_task.delay(str(cad_file.id))
|
||||
queued += 1
|
||||
|
||||
return {"queued": queued, "message": f"Queued {queued} missing STL generation task(s)"}
|
||||
return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"}
|
||||
|
||||
|
||||
@router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
|
||||
|
||||
+25
-216
@@ -262,78 +262,16 @@ async def get_objects(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/stl/{quality}")
|
||||
async def download_stl(
|
||||
id: uuid.UUID,
|
||||
quality: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download the cached STL for a CAD file with a human-readable filename.
|
||||
|
||||
The STL is cached next to the STEP file on first render.
|
||||
quality must be 'low' or 'high'.
|
||||
"""
|
||||
if quality not in ("low", "high"):
|
||||
raise HTTPException(400, detail="quality must be 'low' or 'high'")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
step_path = Path(cad.stored_path)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{quality}.stl"
|
||||
|
||||
if not stl_path.exists():
|
||||
raise HTTPException(
|
||||
404,
|
||||
detail=f"STL cache not found for quality '{quality}'. Trigger a render first to generate it.",
|
||||
)
|
||||
|
||||
original_stem = Path(cad.original_name or "model").stem
|
||||
filename = f"{original_stem}_{quality}.stl"
|
||||
|
||||
return FileResponse(
|
||||
path=str(stl_path),
|
||||
media_type="application/octet-stream",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{id}/generate-stl/{quality}", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_stl(
|
||||
id: uuid.UUID,
|
||||
quality: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue STL generation for the given quality without triggering a full render."""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
if quality not in ("low", "high"):
|
||||
raise HTTPException(status_code=400, detail="quality must be 'low' or 'high'")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
from app.tasks.step_tasks import generate_stl_cache
|
||||
task = generate_stl_cache.delay(str(id), quality)
|
||||
return {"status": "queued", "task_id": task.id, "quality": quality}
|
||||
|
||||
|
||||
@router.post("/{id}/generate-gltf-geometry", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_gltf_geometry(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue GLB geometry export from the existing STL cache (trimesh, no Blender).
|
||||
"""Queue GLB geometry export directly from STEP via OCC (no STL required).
|
||||
|
||||
Stores the result as a MediaAsset with asset_type='gltf_geometry'.
|
||||
The STL low-quality cache must already exist (run a thumbnail render first).
|
||||
Uses export_step_to_gltf.py (OCP/pythonocc) — no Blender needed.
|
||||
"""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
@@ -342,20 +280,34 @@ async def generate_gltf_geometry(
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
step_path = Path(cad.stored_path)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="STL low-quality cache not found. Trigger a render first to generate it.",
|
||||
)
|
||||
|
||||
# Queue as a thumbnail_rendering task (trimesh available in render-worker)
|
||||
from app.tasks.step_tasks import generate_gltf_geometry_task
|
||||
task = generate_gltf_geometry_task.delay(str(id))
|
||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
||||
|
||||
|
||||
@router.post("/{id}/generate-gltf-production", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_gltf_production(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue production GLB export (Blender + PBR materials) from a geometry GLB.
|
||||
|
||||
Requires a gltf_geometry MediaAsset to already exist (run generate-gltf-geometry first).
|
||||
Stores result as a MediaAsset with asset_type='gltf_production'.
|
||||
"""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
from app.tasks.step_tasks import generate_gltf_production_task
|
||||
task = generate_gltf_production_task.delay(str(id))
|
||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{id}/regenerate-thumbnail",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
@@ -396,146 +348,3 @@ async def regenerate_thumbnail(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/export-gltf-colored")
|
||||
async def export_gltf_colored(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Export a GLB with PBR colors from part_colors (material alias mapping).
|
||||
|
||||
Loads per-part STLs from the low-quality parts cache directory and applies
|
||||
PBR materials based on the product's cad_part_materials color assignments.
|
||||
Falls back to the combined STL with a single grey material.
|
||||
"""
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import text, select
|
||||
import trimesh
|
||||
import io
|
||||
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
# Bypass RLS for cad_files + products
|
||||
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(404, detail="STEP file not uploaded")
|
||||
|
||||
step_path = Path(cad.stored_path)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_low.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_low_parts"
|
||||
|
||||
if not stl_path.exists():
|
||||
raise HTTPException(404, detail="STL cache not found. Trigger a render first.")
|
||||
|
||||
# Load settings
|
||||
from app.models.system_setting import SystemSetting
|
||||
settings_result = await db.execute(
|
||||
select(SystemSetting.key, SystemSetting.value).where(
|
||||
SystemSetting.key.in_([
|
||||
"gltf_scale_factor", "gltf_smooth_normals",
|
||||
"gltf_pbr_roughness", "gltf_pbr_metallic",
|
||||
])
|
||||
)
|
||||
)
|
||||
raw_settings = {k: v for k, v in settings_result.all()}
|
||||
scale = float(raw_settings.get("gltf_scale_factor", "0.001"))
|
||||
smooth = raw_settings.get("gltf_smooth_normals", "true") == "true"
|
||||
roughness = float(raw_settings.get("gltf_pbr_roughness", "0.4"))
|
||||
metallic = float(raw_settings.get("gltf_pbr_metallic", "0.6"))
|
||||
|
||||
# Load part colors from product
|
||||
from app.domains.products.models import Product
|
||||
part_colors: dict[str, str] = {}
|
||||
if cad.id:
|
||||
prod_result = await db.execute(
|
||||
select(Product).where(Product.cad_file_id == cad.id).limit(1)
|
||||
)
|
||||
product = prod_result.scalar_one_or_none()
|
||||
if product and product.cad_part_materials:
|
||||
for entry in product.cad_part_materials:
|
||||
part_name = entry.get("part_name") or entry.get("name", "")
|
||||
hex_color = entry.get("hex_color") or entry.get("color", "")
|
||||
if part_name and hex_color:
|
||||
part_colors[part_name] = hex_color
|
||||
|
||||
def _hex_to_rgba(h: str) -> list:
|
||||
h = h.lstrip("#")
|
||||
if len(h) < 6:
|
||||
return [0.7, 0.7, 0.7, 1.0]
|
||||
try:
|
||||
return [int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)] + [1.0]
|
||||
except Exception:
|
||||
return [0.7, 0.7, 0.7, 1.0]
|
||||
|
||||
def _make_material(hex_color: str | None = None):
|
||||
rgba = _hex_to_rgba(hex_color) if hex_color else [0.7, 0.7, 0.7, 1.0]
|
||||
return trimesh.visual.material.PBRMaterial(
|
||||
baseColorFactor=rgba,
|
||||
roughnessFactor=roughness,
|
||||
metallicFactor=metallic,
|
||||
)
|
||||
|
||||
def _apply_mesh(mesh, color=None):
|
||||
mesh.apply_scale(scale)
|
||||
if smooth:
|
||||
try:
|
||||
trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)
|
||||
except Exception:
|
||||
pass
|
||||
mesh.visual = trimesh.visual.TextureVisuals(material=_make_material(color))
|
||||
return mesh
|
||||
|
||||
# Try per-part STLs first
|
||||
scene = trimesh.Scene()
|
||||
used_parts = False
|
||||
|
||||
if parts_dir.exists() and part_colors:
|
||||
for part_name, hex_color in part_colors.items():
|
||||
# Sanitize part name for filesystem
|
||||
safe_name = part_name.replace("/", "_").replace("\\", "_")
|
||||
part_stl = parts_dir / f"{safe_name}.stl"
|
||||
if not part_stl.exists():
|
||||
# Try lowercase / partial match
|
||||
candidates = list(parts_dir.glob(f"{safe_name}*.stl"))
|
||||
if not candidates:
|
||||
candidates = list(parts_dir.glob("*.stl"))
|
||||
candidates = [c for c in candidates if safe_name.lower() in c.stem.lower()]
|
||||
if candidates:
|
||||
part_stl = candidates[0]
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
m = trimesh.load(str(part_stl), force="mesh")
|
||||
_apply_mesh(m, hex_color)
|
||||
scene.add_geometry(m, geom_name=part_name)
|
||||
used_parts = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not used_parts:
|
||||
# Fallback: combined STL, single color
|
||||
combined = trimesh.load(str(stl_path))
|
||||
if hasattr(combined, 'geometry'):
|
||||
for name, m in combined.geometry.items():
|
||||
_apply_mesh(m, next(iter(part_colors.values()), None))
|
||||
scene.add_geometry(m, geom_name=name)
|
||||
else:
|
||||
_apply_mesh(combined, next(iter(part_colors.values()), None))
|
||||
scene.add_geometry(combined)
|
||||
|
||||
# Export to bytes
|
||||
buf = io.BytesIO()
|
||||
scene.export(buf, file_type="glb")
|
||||
glb_bytes = buf.getvalue()
|
||||
|
||||
original_stem = Path(cad.original_name or "model").stem
|
||||
filename = f"{original_stem}_colored.glb"
|
||||
|
||||
return Response(
|
||||
content=glb_bytes,
|
||||
media_type="model/gltf-binary",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
@@ -77,20 +77,9 @@ def _product_out(product: Product, priority: list[str] | None = None) -> Product
|
||||
out.cad_parsed_objects = product.cad_parsed_objects
|
||||
out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None
|
||||
out.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
|
||||
out.stl_cached = _stl_cached_qualities(product)
|
||||
return out
|
||||
|
||||
|
||||
def _stl_cached_qualities(product: Product) -> list[str]:
|
||||
"""Return list of STL qualities that are cached on disk for this product."""
|
||||
from pathlib import Path as _Path
|
||||
cad = product.cad_file
|
||||
if not cad or not cad.stored_path:
|
||||
return []
|
||||
step = _Path(cad.stored_path)
|
||||
return [q for q in ("low", "high") if (step.parent / f"{step.stem}_{q}.stl").exists()]
|
||||
|
||||
|
||||
async def _load_thumbnail_priority(db: AsyncSession) -> list[str]:
|
||||
"""Read product_thumbnail_priority from system_settings.
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ class ProductOut(BaseModel):
|
||||
thumbnail_url: str | None = None
|
||||
render_image_url: str | None = None
|
||||
processing_status: str | None = None
|
||||
stl_cached: list[str] = []
|
||||
cad_parsed_objects: list[str] | None = None
|
||||
cad_mesh_attributes: dict | None = None
|
||||
arbeitspaket: str | None = None
|
||||
|
||||
@@ -193,9 +193,8 @@ def render_turntable_task(
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from app.services.render_blender import (
|
||||
find_blender, convert_step_to_stl, export_per_part_stls
|
||||
)
|
||||
import sys
|
||||
from app.services.render_blender import find_blender
|
||||
|
||||
blender_bin = find_blender()
|
||||
if not blender_bin:
|
||||
@@ -208,27 +207,25 @@ def render_turntable_task(
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
turntable_script = scripts_dir / "turntable_render.py"
|
||||
|
||||
# STL conversion — try MinIO cache first, then convert locally
|
||||
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
try:
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
||||
step_hash = compute_step_hash(str(step))
|
||||
cached = check_stl_cache(step_hash, stl_quality)
|
||||
if cached:
|
||||
stl_path.write_bytes(cached)
|
||||
logger.info("STL restored from MinIO cache: %s", stl_path.name)
|
||||
else:
|
||||
convert_step_to_stl(step, stl_path, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc)
|
||||
convert_step_to_stl(step, stl_path, stl_quality)
|
||||
parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts"
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part export non-fatal: %s", exc)
|
||||
# GLB generation via OCC (replaces STL intermediary)
|
||||
linear_deflection = 0.3 if stl_quality == "low" else 0.05
|
||||
angular_deflection = 0.3 if stl_quality == "low" else 0.1
|
||||
glb_path = step.parent / f"{step.stem}_{stl_quality}.glb"
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||
occ_cmd = [
|
||||
sys.executable, str(occ_script),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(glb_path),
|
||||
"--linear_deflection", str(linear_deflection),
|
||||
"--angular_deflection", str(angular_deflection),
|
||||
]
|
||||
occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
||||
if occ_result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}"
|
||||
)
|
||||
logger.info("render_turntable_task: GLB generated: %s", glb_path.name)
|
||||
|
||||
# Build turntable render arguments
|
||||
frames_dir = out_dir / "frames"
|
||||
@@ -238,7 +235,7 @@ def render_turntable_task(
|
||||
blender_bin, "--background",
|
||||
"--python", str(turntable_script),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(frames_dir),
|
||||
output_name,
|
||||
str(width), str(height),
|
||||
@@ -463,93 +460,40 @@ 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 GLB from the STL cache via Blender subprocess (with trimesh fallback).
|
||||
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
|
||||
|
||||
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.
|
||||
Publishes a MediaAsset with asset_type='gltf_geometry'.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
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}")
|
||||
|
||||
step = Path(step_path_str)
|
||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
raise RuntimeError(
|
||||
f"STL cache not found: {stl_path}. Run thumbnail generation first."
|
||||
)
|
||||
|
||||
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"
|
||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
if not occ_script.exists():
|
||||
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
|
||||
|
||||
asset_type = "gltf_geometry"
|
||||
|
||||
# Load sharp edge hints from mesh_attributes for UV seam marking
|
||||
sharp_edges_json = "[]"
|
||||
if cad_file_id:
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _load_mesh_attrs() -> list:
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.cad_file import CadFile as _CF
|
||||
from sqlalchemy import select as _sel
|
||||
async with AsyncSessionLocal() as _db:
|
||||
_res = await _db.execute(_sel(_CF).where(_CF.id == cad_file_id))
|
||||
_cad = _res.scalar_one_or_none()
|
||||
if _cad and _cad.mesh_attributes:
|
||||
return _cad.mesh_attributes.get("sharp_edge_midpoints") or []
|
||||
return []
|
||||
|
||||
_midpoints = _asyncio.get_event_loop().run_until_complete(_load_mesh_attrs())
|
||||
if _midpoints:
|
||||
sharp_edges_json = json.dumps(_midpoints)
|
||||
except Exception as _exc:
|
||||
logger.warning("Could not load sharp_edge_midpoints for %s: %s", cad_file_id, _exc)
|
||||
|
||||
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({}),
|
||||
"--sharp_edges_json", sharp_edges_json,
|
||||
]
|
||||
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, 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"}
|
||||
cmd = [
|
||||
sys.executable, str(occ_script),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(output_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
)
|
||||
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name)
|
||||
return {"glb_path": str(output_path), "method": "occ"}
|
||||
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)
|
||||
@@ -576,9 +520,20 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
||||
|
||||
step = Path(step_path_str)
|
||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
||||
# Use geometry GLB as input (generate if missing)
|
||||
glb_path = step.parent / f"{step.stem}_geometry.glb"
|
||||
if not glb_path.exists():
|
||||
import subprocess as _sp
|
||||
import sys as _sys
|
||||
scripts_dir_tmp = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
occ_cmd = [
|
||||
_sys.executable, str(scripts_dir_tmp / "export_step_to_gltf.py"),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(glb_path),
|
||||
]
|
||||
occ_res = _sp.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
||||
if occ_res.returncode != 0:
|
||||
raise RuntimeError(f"GLB generation failed:\n{occ_res.stderr[-500:]}")
|
||||
|
||||
output_path = step.parent / f"{step.stem}_production.blend"
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
@@ -617,7 +572,7 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
blender_bin, "--background",
|
||||
"--python", str(export_script),
|
||||
"--",
|
||||
"--stl_path", str(stl_path),
|
||||
"--glb_path", str(glb_path),
|
||||
"--output_path", str(output_path),
|
||||
"--asset_library_blend", asset_lib_path,
|
||||
"--material_map", json.dumps(mat_map),
|
||||
@@ -670,7 +625,7 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
|
||||
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
|
||||
glb_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_geometry.glb") if cad else None
|
||||
|
||||
# Resolve asset library blend path
|
||||
try:
|
||||
@@ -681,24 +636,24 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
|
||||
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
|
||||
return glb_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"}
|
||||
glb_path, blend_path, mat_map = result
|
||||
if not glb_path or not Path(glb_path).exists():
|
||||
logger.warning("Geometry GLB not found for %s", order_line_id)
|
||||
return {"status": "skipped", "reason": "glb_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,
|
||||
"--glb_path", glb_path,
|
||||
"--asset_library_blend", blend_path or "",
|
||||
"--material_map", json.dumps(mat_map),
|
||||
]
|
||||
|
||||
@@ -17,24 +17,39 @@ logger = logging.getLogger(__name__)
|
||||
MIN_BLENDER_VERSION = (5, 0, 1)
|
||||
|
||||
|
||||
def _stl_from_cache_or_convert(step_path: Path, stl_path: Path, quality: str) -> None:
|
||||
"""Try MinIO cache first, then fall back to local STEP→STL conversion."""
|
||||
# MinIO cache check (non-fatal — cache miss just means we convert normally)
|
||||
try:
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
||||
step_hash = compute_step_hash(str(step_path))
|
||||
cached_bytes = check_stl_cache(step_hash, quality)
|
||||
if cached_bytes:
|
||||
stl_path.write_bytes(cached_bytes)
|
||||
logger.info("STL restored from MinIO cache: %s (%d KB)", stl_path.name, len(cached_bytes) // 1024)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("MinIO cache check failed (non-fatal): %s", exc)
|
||||
def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None:
|
||||
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed).
|
||||
|
||||
# Local conversion
|
||||
from app.services.step_processor import convert_step_to_stl
|
||||
logger.info("STL cache miss — converting: %s", step_path.name)
|
||||
convert_step_to_stl(step_path, stl_path, quality)
|
||||
quality: "low" → coarser mesh (~0.3 mm deflection, fast)
|
||||
"high" → finer mesh (~0.05 mm deflection, slower)
|
||||
"""
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
|
||||
linear_deflection = 0.3 if quality == "low" else 0.05
|
||||
angular_deflection = 0.3 if quality == "low" else 0.1
|
||||
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
cmd = [
|
||||
_sys.executable, str(script_path),
|
||||
"--step_path", str(step_path),
|
||||
"--output_path", str(glb_path),
|
||||
"--linear_deflection", str(linear_deflection),
|
||||
"--angular_deflection", str(angular_deflection),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
for line in result.stdout.splitlines():
|
||||
logger.info("[occ-gltf] %s", line)
|
||||
for line in result.stderr.splitlines():
|
||||
logger.warning("[occ-gltf stderr] %s", line)
|
||||
if result.returncode != 0 or not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
|
||||
f"STDERR: {result.stderr[-1000:]}"
|
||||
)
|
||||
logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
|
||||
|
||||
def find_blender() -> str:
|
||||
@@ -51,127 +66,6 @@ def is_blender_available() -> bool:
|
||||
return bool(find_blender())
|
||||
|
||||
|
||||
def convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None:
|
||||
"""Convert a STEP file to STL using cadquery.
|
||||
|
||||
Raises ImportError if cadquery is not installed (not available in backend
|
||||
container — only in render-worker container).
|
||||
"""
|
||||
import cadquery as cq # only available in render-worker
|
||||
|
||||
if quality == "high":
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02)
|
||||
else:
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3)
|
||||
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
raise RuntimeError("cadquery produced empty STL")
|
||||
|
||||
|
||||
def export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list:
|
||||
"""Export one STL per named STEP leaf shape using OCP XCAF.
|
||||
|
||||
Returns the manifest list (may be empty on failure — non-fatal).
|
||||
"""
|
||||
tol = 0.01 if quality == "high" else 0.3
|
||||
angular_tol = 0.05 if quality == "high" else 0.3
|
||||
|
||||
try:
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
|
||||
from OCP.TDataStd import TDataStd_Name
|
||||
from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.TCollection import TCollection_ExtendedString
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
import cadquery as cq
|
||||
except ImportError as e:
|
||||
logger.warning("per-part export skipped (import error): %s", e)
|
||||
return []
|
||||
|
||||
app = XCAFApp_Application.GetApplication_s()
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
|
||||
app.InitDocument(doc)
|
||||
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
status = reader.ReadFile(str(step_path))
|
||||
if status != IFSelect_RetDone:
|
||||
logger.warning("XCAF reader failed with status %s", status)
|
||||
return []
|
||||
|
||||
if not reader.Transfer(doc):
|
||||
logger.warning("XCAF transfer failed")
|
||||
return []
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
name_id = TDataStd_Name.GetID_s()
|
||||
|
||||
leaves = []
|
||||
|
||||
def _get_label_name(label):
|
||||
name_attr = TDataStd_Name()
|
||||
if label.FindAttribute(name_id, name_attr):
|
||||
return name_attr.Get().ToExtString()
|
||||
return ""
|
||||
|
||||
def _collect_leaves(label):
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(label):
|
||||
components = TDF_LabelSequence()
|
||||
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||
for i in range(1, components.Length() + 1):
|
||||
comp_label = components.Value(i)
|
||||
if XCAFDoc_ShapeTool.IsReference_s(comp_label):
|
||||
ref_label = TDF_Label_cls()
|
||||
XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label)
|
||||
comp_name = _get_label_name(comp_label)
|
||||
ref_name = _get_label_name(ref_label)
|
||||
name = ref_name or comp_name
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
|
||||
_collect_leaves(ref_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(comp_label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
else:
|
||||
_collect_leaves(comp_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(label):
|
||||
name = _get_label_name(label)
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
|
||||
top_labels = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(top_labels)
|
||||
for i in range(1, top_labels.Length() + 1):
|
||||
_collect_leaves(top_labels.Value(i))
|
||||
|
||||
if not leaves:
|
||||
logger.warning("no leaf shapes found via XCAF")
|
||||
return []
|
||||
|
||||
parts_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = []
|
||||
|
||||
for idx, (name, shape) in enumerate(leaves):
|
||||
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
filename = f"{idx:02d}_{safe_name}.stl"
|
||||
filepath = str(parts_dir / filename)
|
||||
try:
|
||||
cq_shape = cq.Shape(shape)
|
||||
cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol)
|
||||
manifest.append({"index": idx, "name": name, "file": filename})
|
||||
except Exception as e:
|
||||
logger.warning("failed to export part '%s': %s", name, e)
|
||||
|
||||
manifest_path = parts_dir / "manifest.json"
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump({"parts": manifest}, f, indent=2)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def render_still(
|
||||
step_path: Path,
|
||||
output_path: Path,
|
||||
@@ -202,7 +96,7 @@ def render_still(
|
||||
denoising_use_gpu: str = "",
|
||||
mesh_attributes: dict | None = None,
|
||||
) -> dict:
|
||||
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
||||
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
|
||||
|
||||
Returns a dict with timing, sizes, engine_used, and log_lines.
|
||||
Raises RuntimeError on failure.
|
||||
@@ -215,7 +109,6 @@ def render_still(
|
||||
|
||||
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
|
||||
if not script_path.exists():
|
||||
# Fallback: look next to this file (development mode)
|
||||
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
|
||||
if alt.exists():
|
||||
script_path = alt
|
||||
@@ -224,24 +117,16 @@ def render_still(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# 1. STL conversion (cadquery)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
||||
t_glb = time.monotonic()
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||
else:
|
||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
|
||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||
|
||||
# 2. Blender render
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -263,7 +148,7 @@ def render_still(
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(output_path),
|
||||
str(width), str(height),
|
||||
eng, str(samples), str(smooth_angle),
|
||||
@@ -332,22 +217,13 @@ def render_still(
|
||||
|
||||
render_duration_s = round(time.monotonic() - t_render, 2)
|
||||
|
||||
parts_count = 0
|
||||
manifest_file = parts_dir / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
try:
|
||||
data = json.loads(manifest_file.read_text())
|
||||
parts_count = len(data.get("parts", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
|
||||
"render_duration_s": render_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"stl_size_bytes": glb_size_bytes,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"parts_count": parts_count,
|
||||
"parts_count": 0,
|
||||
"engine_used": engine_used,
|
||||
"log_lines": log_lines,
|
||||
}
|
||||
@@ -407,24 +283,15 @@ def render_turntable_to_file(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# 1. STL conversion
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
||||
t_glb = time.monotonic()
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||
else:
|
||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||
|
||||
# 2. Render frames with Blender
|
||||
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
|
||||
@@ -439,7 +306,7 @@ def render_turntable_to_file(
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(frames_dir),
|
||||
str(frame_count),
|
||||
"360", # degrees
|
||||
@@ -554,10 +421,10 @@ def render_turntable_to_file(
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
|
||||
"render_duration_s": render_duration_s,
|
||||
"ffmpeg_duration_s": ffmpeg_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"stl_size_bytes": 0,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"frame_count": len(frame_files),
|
||||
"engine_used": engine,
|
||||
|
||||
+215
-172
@@ -1,6 +1,5 @@
|
||||
"""Celery tasks for STEP file processing and thumbnail generation."""
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.core.task_logs import log_task_event
|
||||
@@ -8,44 +7,37 @@ from app.core.task_logs import log_task_event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bbox_from_stl(stl_path: str) -> dict | None:
|
||||
"""Extract bounding box from a cached binary STL file.
|
||||
def _bbox_from_glb(glb_path: str) -> dict | None:
|
||||
"""Extract bounding box from a GLB file (meters → converted to mm).
|
||||
|
||||
Returns {"dimensions_mm": {x,y,z}, "bbox_center_mm": {x,y,z}} or None on failure.
|
||||
Reading vertex extremes from an existing STL is ~10-100× faster than re-parsing STEP.
|
||||
OCC GLB output is in meters; multiply by 1000 to get mm.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
p = Path(stl_path)
|
||||
if not p.exists() or p.stat().st_size < 84:
|
||||
import trimesh
|
||||
p = Path(glb_path)
|
||||
if not p.exists():
|
||||
return None
|
||||
with p.open("rb") as f:
|
||||
f.seek(80) # skip 80-byte header
|
||||
n = struct.unpack("<I", f.read(4))[0]
|
||||
if n == 0:
|
||||
return None
|
||||
raw = f.read(n * 50) # 50 bytes per triangle
|
||||
# Binary STL per-triangle layout: normal(12B) + v1(12B) + v2(12B) + v3(12B) + attr(2B) = 50B
|
||||
# Extract vertex bytes (columns 12..48 of each 50-byte row)
|
||||
arr = np.frombuffer(raw, dtype=np.uint8).reshape(n, 50)
|
||||
verts = np.frombuffer(arr[:, 12:48].tobytes(), dtype=np.float32).reshape(-1, 3)
|
||||
mins = verts.min(axis=0)
|
||||
maxs = verts.max(axis=0)
|
||||
scene = trimesh.load(str(p), force="scene")
|
||||
bounds = getattr(scene, "bounds", None)
|
||||
if bounds is None:
|
||||
return None
|
||||
mins, maxs = bounds
|
||||
dims = maxs - mins
|
||||
return {
|
||||
"dimensions_mm": {
|
||||
"x": round(float(dims[0]), 2),
|
||||
"y": round(float(dims[1]), 2),
|
||||
"z": round(float(dims[2]), 2),
|
||||
"x": round(float(dims[0]) * 1000, 2),
|
||||
"y": round(float(dims[1]) * 1000, 2),
|
||||
"z": round(float(dims[2]) * 1000, 2),
|
||||
},
|
||||
"bbox_center_mm": {
|
||||
"x": round(float((mins[0] + maxs[0]) / 2), 2),
|
||||
"y": round(float((mins[1] + maxs[1]) / 2), 2),
|
||||
"z": round(float((mins[2] + maxs[2]) / 2), 2),
|
||||
"x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2),
|
||||
"y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2),
|
||||
"z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2),
|
||||
},
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug(f"_bbox_from_stl failed for {stl_path}: {exc}")
|
||||
logger.debug(f"_bbox_from_glb failed for {glb_path}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -229,9 +221,9 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
logger.error(f"Thumbnail render failed for {cad_file_id}: {exc}")
|
||||
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
||||
|
||||
# Extract bounding box from the STL that was just cached by the renderer.
|
||||
# STL binary parsing is near-instant (numpy min/max) vs re-parsing the STEP file.
|
||||
# Falls back to cadquery STEP re-parse if STL is not found.
|
||||
# Extract bounding box from the thumbnail GLB generated by the renderer.
|
||||
# GLB bbox via trimesh is fast and avoids re-parsing the STEP file.
|
||||
# Falls back to cadquery STEP re-parse if GLB is not found.
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -247,8 +239,8 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
|
||||
if _step_path and not (_cad2.mesh_attributes or {}).get("dimensions_mm"):
|
||||
_step = Path(_step_path)
|
||||
_stl = _step.parent / f"{_step.stem}_low.stl"
|
||||
bbox_data = _bbox_from_stl(str(_stl)) or _bbox_from_step_cadquery(_step_path)
|
||||
_glb = _step.parent / f"{_step.stem}_thumbnail.glb"
|
||||
bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(_step_path)
|
||||
if bbox_data:
|
||||
_eng2 = create_engine(_sync_url2)
|
||||
with Session(_eng2) as _sess2:
|
||||
@@ -295,6 +287,13 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
except Exception:
|
||||
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
|
||||
|
||||
# Auto-generate geometry GLB so the 3D viewer is ready without manual trigger
|
||||
try:
|
||||
generate_gltf_geometry_task.delay(cad_file_id)
|
||||
logger.info("render_step_thumbnail: queued generate_gltf_geometry_task for %s", cad_file_id)
|
||||
except Exception:
|
||||
logger.debug("Could not queue generate_gltf_geometry_task (non-fatal)")
|
||||
|
||||
|
||||
@celery_app.task(name="app.tasks.step_tasks.reextract_cad_metadata", queue="thumbnail_rendering")
|
||||
def reextract_cad_metadata(cad_file_id: str):
|
||||
@@ -321,8 +320,8 @@ def reextract_cad_metadata(cad_file_id: str):
|
||||
|
||||
try:
|
||||
p = Path(step_path)
|
||||
stl_path = p.parent / f"{p.stem}_low.stl"
|
||||
patch = _bbox_from_stl(str(stl_path)) or _bbox_from_step_cadquery(step_path)
|
||||
glb_path = p.parent / f"{p.stem}_thumbnail.glb"
|
||||
patch = _bbox_from_glb(str(glb_path)) or _bbox_from_step_cadquery(step_path)
|
||||
if patch:
|
||||
with Session(eng) as session:
|
||||
cad_file = session.get(CadFile, cad_file_id)
|
||||
@@ -342,72 +341,22 @@ def reextract_cad_metadata(cad_file_id: str):
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_stl_cache", queue="thumbnail_rendering")
|
||||
def generate_stl_cache(self, cad_file_id: str, quality: str):
|
||||
"""Generate and cache STL for a CAD file without triggering a full render."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import settings as app_settings
|
||||
from app.models.cad_file import CadFile
|
||||
|
||||
logger.info(f"Generating {quality}-quality STL for CAD file: {cad_file_id}")
|
||||
|
||||
sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
eng = create_engine(sync_url)
|
||||
with Session(eng) as session:
|
||||
cad_file = session.get(CadFile, cad_file_id)
|
||||
if not cad_file or not cad_file.stored_path:
|
||||
logger.error(f"CAD file not found or no stored_path: {cad_file_id}")
|
||||
return
|
||||
step_path = cad_file.stored_path
|
||||
eng.dispose()
|
||||
|
||||
try:
|
||||
from app.services.render_blender import convert_step_to_stl, export_per_part_stls
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache, store_stl_cache
|
||||
from pathlib import Path as _Path
|
||||
step = _Path(step_path)
|
||||
stl_out = step.parent / f"{step.stem}_{quality}.stl"
|
||||
parts_dir = step.parent / f"{step.stem}_{quality}_parts"
|
||||
|
||||
if not stl_out.exists() or stl_out.stat().st_size == 0:
|
||||
# Check MinIO cache before running cadquery conversion
|
||||
step_hash = compute_step_hash(step_path)
|
||||
cached_bytes = check_stl_cache(step_hash, quality)
|
||||
if cached_bytes:
|
||||
stl_out.write_bytes(cached_bytes)
|
||||
logger.info(f"STL cache hit for {cad_file_id} ({quality}), skipped conversion")
|
||||
else:
|
||||
convert_step_to_stl(step, stl_out, quality)
|
||||
# Store result in MinIO for future workers
|
||||
if stl_out.exists() and stl_out.stat().st_size > 0:
|
||||
store_stl_cache(step_hash, quality, str(stl_out))
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step, parts_dir, quality)
|
||||
except Exception as pe:
|
||||
logger.warning(f"Per-part STL export non-fatal: {pe}")
|
||||
logger.info(f"STL cached: {stl_out} ({stl_out.stat().st_size // 1024} KB)")
|
||||
except Exception as exc:
|
||||
logger.error(f"STL generation failed for {cad_file_id} quality={quality}: {exc}")
|
||||
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_gltf_geometry_task", queue="thumbnail_rendering", max_retries=1)
|
||||
def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
"""Export a GLB via Blender with material substitution and OCC sharp-edge data.
|
||||
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
|
||||
|
||||
Pipeline:
|
||||
1. Reads sharp_edge_midpoints from cad_file.mesh_attributes (from OCC extraction)
|
||||
2. Resolves material_map via alias lookup (part_name → SCHAEFFLER library material)
|
||||
3. Runs Blender headless with export_gltf.py: STL → GLB with library materials + sharp edges
|
||||
4. Falls back to trimesh (geometry-only, no materials) if Blender is unavailable
|
||||
1. Reads STEP file directly (no STL needed)
|
||||
2. Builds color_map from product.cad_part_materials (hex colors)
|
||||
3. Runs export_step_to_gltf.py (Python/OCP): STEP → GLB with per-part colors
|
||||
4. Stores result as gltf_geometry MediaAsset (replaces any existing one)
|
||||
|
||||
Replaces the existing gltf_geometry MediaAsset for this CadFile on each run.
|
||||
Output is in meters, Y-up (glTF convention).
|
||||
"""
|
||||
import json as _json
|
||||
import os as _os
|
||||
import subprocess as _subprocess
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
from sqlalchemy import create_engine, select as _select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -422,115 +371,68 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
logger.error("generate_gltf_geometry_task: no stored_path for %s", cad_file_id)
|
||||
return
|
||||
step_path_str = cad_file.stored_path
|
||||
mesh_attrs = cad_file.mesh_attributes or {}
|
||||
sharp_edge_midpoints = mesh_attrs.get("sharp_edge_midpoints", [])
|
||||
|
||||
# Load product materials for this CAD file
|
||||
# Build hex color_map from product.cad_part_materials
|
||||
from app.domains.products.models import Product
|
||||
product = session.execute(
|
||||
_select(Product).where(Product.cad_file_id == cad_file.id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
raw_material_map: dict[str, str] = {}
|
||||
color_map: dict[str, str] = {}
|
||||
product_id = str(product.id) if product else None
|
||||
if product and product.cad_part_materials:
|
||||
for entry in product.cad_part_materials:
|
||||
part_name = entry.get("part_name") or entry.get("name", "")
|
||||
mat_name = entry.get("material_name") or entry.get("material", "")
|
||||
if part_name and mat_name:
|
||||
raw_material_map[part_name] = mat_name
|
||||
hex_color = entry.get("hex_color") or entry.get("color", "")
|
||||
if part_name and hex_color:
|
||||
color_map[part_name] = hex_color
|
||||
eng.dispose()
|
||||
|
||||
# Resolve aliases: "Steel--Stahl" → "SCHAEFFLER_010101_Steel-Bare"
|
||||
from app.services.material_service import resolve_material_map
|
||||
material_map = resolve_material_map(raw_material_map)
|
||||
|
||||
# Get asset library .blend path from system settings
|
||||
from app.services.template_service import get_material_library_path
|
||||
asset_library_blend = get_material_library_path()
|
||||
|
||||
step = _Path(step_path_str)
|
||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error")
|
||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
||||
if not step.exists():
|
||||
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
|
||||
raise RuntimeError(f"STEP file not found: {step}")
|
||||
|
||||
output_path = step.parent / f"{step.stem}_geometry.glb"
|
||||
|
||||
log_task_event(
|
||||
self.request.id,
|
||||
f"Starting GLB export: {len(material_map)} materials, "
|
||||
f"{len(sharp_edge_midpoints)} sharp-edge hints, "
|
||||
f"library={'yes' if asset_library_blend else 'no'}",
|
||||
f"Starting OCC GLB export: {len(color_map)} part colors",
|
||||
"info",
|
||||
)
|
||||
|
||||
# --- Blender path ---
|
||||
blender_bin = _os.environ.get("BLENDER_BIN", "blender")
|
||||
# Run export_step_to_gltf.py as a subprocess so OCP imports don't pollute worker state
|
||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script_path = scripts_dir / "export_gltf.py"
|
||||
blender_ok = False
|
||||
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
if _Path(blender_bin).exists() and script_path.exists():
|
||||
cmd = [
|
||||
blender_bin, "--background", "--python", str(script_path), "--",
|
||||
"--stl_path", str(stl_path),
|
||||
"--output_path", str(output_path),
|
||||
"--material_map", _json.dumps(material_map),
|
||||
"--sharp_edges_json", _json.dumps(sharp_edge_midpoints),
|
||||
]
|
||||
if asset_library_blend and _Path(asset_library_blend).exists():
|
||||
cmd += ["--asset_library_blend", asset_library_blend]
|
||||
python_bin = _sys.executable
|
||||
cmd = [
|
||||
python_bin, str(script_path),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(output_path),
|
||||
"--color_map", _json.dumps(color_map),
|
||||
]
|
||||
|
||||
try:
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
|
||||
blender_ok = True
|
||||
logger.info("generate_gltf_geometry_task: Blender export succeeded (%s KB)",
|
||||
output_path.stat().st_size // 1024)
|
||||
else:
|
||||
logger.warning(
|
||||
"Blender GLB export failed (exit %d) — falling back to trimesh.\n"
|
||||
"STDOUT: %s\nSTDERR: %s",
|
||||
result.returncode, result.stdout[-1500:], result.stderr[-500:],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Blender GLB export error (%s) — falling back to trimesh", exc)
|
||||
else:
|
||||
logger.warning(
|
||||
"Blender not available at '%s' or script missing at '%s' — using trimesh fallback",
|
||||
blender_bin, script_path,
|
||||
)
|
||||
try:
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
for line in result.stdout.splitlines():
|
||||
logger.info("[occ-gltf] %s", line)
|
||||
for line in result.stderr.splitlines():
|
||||
logger.warning("[occ-gltf stderr] %s", line)
|
||||
|
||||
# --- Trimesh fallback (geometry only, no materials) ---
|
||||
if not blender_ok:
|
||||
try:
|
||||
import trimesh
|
||||
import trimesh as _trimesh
|
||||
if result.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
|
||||
f"STDERR: {result.stderr[-1000:]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
||||
logger.error("generate_gltf_geometry_task OCC export failed: %s", exc)
|
||||
raise self.retry(exc=exc, countdown=15)
|
||||
|
||||
def _process_mesh(m):
|
||||
m.apply_scale(0.001)
|
||||
try:
|
||||
_trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mesh = trimesh.load(str(stl_path))
|
||||
if hasattr(mesh, 'geometry'):
|
||||
for sub in mesh.geometry.values():
|
||||
_process_mesh(sub)
|
||||
else:
|
||||
_process_mesh(mesh)
|
||||
mesh.export(str(output_path))
|
||||
log_task_event(self.request.id, "Trimesh fallback export completed (no materials)", "done")
|
||||
except Exception as exc:
|
||||
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
||||
logger.error("generate_gltf_geometry_task trimesh fallback failed: %s", exc)
|
||||
raise self.retry(exc=exc, countdown=15)
|
||||
else:
|
||||
log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done")
|
||||
log_task_event(self.request.id, f"OCC GLB export completed: {output_path.name}", "done")
|
||||
|
||||
# --- Store MediaAsset (replace existing gltf_geometry for this cad_file) ---
|
||||
# Use sync SQLAlchemy to avoid asyncio event-loop conflicts in Celery workers.
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import create_engine as _ce, delete as _del
|
||||
from sqlalchemy.orm import Session as _Session
|
||||
@@ -551,6 +453,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
_key = _key[len(_prefix):]
|
||||
asset = MediaAsset(
|
||||
cad_file_id=_uuid.UUID(cad_file_id),
|
||||
product_id=_uuid.UUID(product_id) if product_id else None,
|
||||
asset_type=MediaAssetType.gltf_geometry,
|
||||
storage_key=_key,
|
||||
mime_type="model/gltf-binary",
|
||||
@@ -565,6 +468,146 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
return {"glb_path": str(output_path), "asset_id": asset_id}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.tasks.step_tasks.generate_gltf_production_task",
|
||||
queue="thumbnail_rendering",
|
||||
max_retries=2,
|
||||
)
|
||||
def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None = None) -> dict:
|
||||
"""Generate a production GLB (Blender + PBR materials) from a geometry GLB via export_gltf.py.
|
||||
|
||||
1. Ensures a gltf_geometry MediaAsset exists (runs OCC export inline if not).
|
||||
2. Resolves SCHAEFFLER material map for the CadFile's product.
|
||||
3. Runs Blender headless with export_gltf.py → production GLB.
|
||||
4. Stores result as gltf_production MediaAsset.
|
||||
"""
|
||||
import json as _json
|
||||
import os as _os
|
||||
import subprocess as _subprocess
|
||||
import uuid as _uuid
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from sqlalchemy import create_engine as _ce, delete as _del, select as _sel
|
||||
from sqlalchemy.orm import Session as _Session
|
||||
|
||||
from app.config import settings as app_settings
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
|
||||
log_task_event(self.request.id, f"generate_gltf_production_task started for cad {cad_file_id}", "info")
|
||||
|
||||
_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 ---
|
||||
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")
|
||||
|
||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
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():
|
||||
raise RuntimeError(f"export_gltf.py not found at {export_script}")
|
||||
|
||||
blender_bin = find_blender()
|
||||
cmd = [
|
||||
blender_bin, "--background",
|
||||
"--python", str(export_script),
|
||||
"--",
|
||||
"--glb_path", str(geom_glb_path),
|
||||
"--output_path", str(output_path),
|
||||
"--material_map", _json.dumps(mat_map),
|
||||
]
|
||||
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")
|
||||
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:]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
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)
|
||||
|
||||
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
|
||||
|
||||
# --- 4. Store MediaAsset (replace existing gltf_production for this cad_file) ---
|
||||
_eng2 = _ce(_sync_url)
|
||||
with _Session(_eng2) as _sess:
|
||||
_sess.execute(
|
||||
_del(MediaAsset).where(
|
||||
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
|
||||
MediaAsset.asset_type == MediaAssetType.gltf_production,
|
||||
)
|
||||
)
|
||||
_key = str(output_path)
|
||||
_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
|
||||
if _key.startswith(_prefix):
|
||||
_key = _key[len(_prefix):]
|
||||
asset = MediaAsset(
|
||||
cad_file_id=_uuid.UUID(cad_file_id),
|
||||
product_id=_uuid.UUID(product_id) if product_id else None,
|
||||
asset_type=MediaAssetType.gltf_production,
|
||||
storage_key=_key,
|
||||
mime_type="model/gltf-binary",
|
||||
file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
|
||||
)
|
||||
_sess.add(asset)
|
||||
_sess.commit()
|
||||
asset_id = str(asset.id)
|
||||
_eng2.dispose()
|
||||
|
||||
logger.info("generate_gltf_production_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
|
||||
return {"glb_path": str(output_path), "asset_id": asset_id}
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.regenerate_thumbnail", queue="thumbnail_rendering")
|
||||
def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
|
||||
"""Regenerate thumbnail with per-part colours."""
|
||||
|
||||
Reference in New Issue
Block a user