fix: media thumbnails, product dimensions, inline 3D viewer, GLB export
Bug A: Media Library thumbnails were gray because <img src> cannot send JWT auth headers. Added useAuthBlob() hook (fetch + createObjectURL) in MediaBrowser.tsx. Also fixed publish_asset Celery task to populate product_id + cad_file_id on MediaAsset for thumbnail fallback resolution. Bug B: Product dimensions now shown in Product Details card with Ruler icon and "from CAD" label when cad_mesh_attributes.dimensions_mm exists. Bug C: Replaced 128×128 CAD thumbnail with InlineCadViewer component. Queries gltf_geometry MediaAssets, fetches GLB via auth fetch → blob URL → Three.js Canvas with OrbitControls. Falls back to thumbnail + "Load 3D Model" button. Polling when GLB generation is in progress. Bug D: trimesh was in [cad] optional extra but Dockerfile only installed [dev]. Changed to pip install -e ".[dev,cad]" — trimesh now available in backend container, GLB + Colors export works. Also added bbox extraction (STL-first numpy parsing) in render_step_thumbnail and admin "Re-extract CAD Metadata" bulk endpoint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,36 @@ from app.core.task_logs import log_task_event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _update_workflow_run_status(order_line_id: str, status: str, error: str | None = None) -> None:
|
||||
"""Update the most recent WorkflowRun for an order_line after task completion."""
|
||||
try:
|
||||
import asyncio
|
||||
from datetime import datetime as _dt
|
||||
|
||||
async def _run():
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.domains.rendering.models import WorkflowRun
|
||||
from sqlalchemy import select as _sel
|
||||
async with AsyncSessionLocal() as db:
|
||||
res = await db.execute(
|
||||
_sel(WorkflowRun)
|
||||
.where(WorkflowRun.order_line_id == order_line_id)
|
||||
.order_by(WorkflowRun.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
run = res.scalar_one_or_none()
|
||||
if run and run.status == "pending":
|
||||
run.status = status
|
||||
run.completed_at = _dt.utcnow()
|
||||
if error:
|
||||
run.error_message = error[:2000]
|
||||
await db.commit()
|
||||
|
||||
asyncio.get_event_loop().run_until_complete(_run())
|
||||
except Exception as _exc:
|
||||
logger.warning("Failed to update WorkflowRun status for line %s: %s", order_line_id, _exc)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.domains.rendering.tasks.render_still_task",
|
||||
@@ -291,6 +321,7 @@ def publish_asset(
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
from app.domains.orders.models import OrderLine
|
||||
from app.domains.products.models import Product
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -298,9 +329,20 @@ def publish_asset(
|
||||
line = res.scalar_one_or_none()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Resolve cad_file_id from the linked product
|
||||
cad_file_id = None
|
||||
if line.product_id:
|
||||
prod_res = await db.execute(select(Product).where(Product.id == line.product_id))
|
||||
product = prod_res.scalar_one_or_none()
|
||||
if product:
|
||||
cad_file_id = product.cad_file_id
|
||||
|
||||
asset = MediaAsset(
|
||||
tenant_id=getattr(line, "tenant_id", None),
|
||||
order_line_id=line.id,
|
||||
product_id=line.product_id,
|
||||
cad_file_id=cad_file_id,
|
||||
asset_type=MediaAssetType(asset_type),
|
||||
storage_key=storage_key,
|
||||
render_config=render_config,
|
||||
@@ -396,6 +438,7 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
_update_workflow_run_status(order_line_id, "completed")
|
||||
return result
|
||||
except Exception as exc:
|
||||
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
||||
@@ -409,6 +452,7 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
_update_workflow_run_status(order_line_id, "failed", str(exc))
|
||||
raise self.retry(exc=exc, countdown=30)
|
||||
|
||||
|
||||
@@ -448,6 +492,29 @@ def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
|
||||
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 = [
|
||||
@@ -458,6 +525,7 @@ def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
"--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)
|
||||
|
||||
Reference in New Issue
Block a user