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:
2026-03-07 13:27:46 +01:00
parent 10ed1b5e91
commit bfd58e3419
24 changed files with 1502 additions and 218 deletions
+189 -5
View File
@@ -1,11 +1,76 @@
"""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
logger = logging.getLogger(__name__)
def _bbox_from_stl(stl_path: str) -> dict | None:
"""Extract bounding box from a cached binary STL file.
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.
"""
try:
import numpy as np
p = Path(stl_path)
if not p.exists() or p.stat().st_size < 84:
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)
dims = maxs - mins
return {
"dimensions_mm": {
"x": round(float(dims[0]), 2),
"y": round(float(dims[1]), 2),
"z": round(float(dims[2]), 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),
},
}
except Exception as exc:
logger.debug(f"_bbox_from_stl failed for {stl_path}: {exc}")
return None
def _bbox_from_step_cadquery(step_path: str) -> dict | None:
"""Fallback: extract bounding box by re-parsing STEP via cadquery."""
try:
import cadquery as cq
bb = cq.importers.importStep(step_path).val().BoundingBox()
return {
"dimensions_mm": {
"x": round(bb.xlen, 2),
"y": round(bb.ylen, 2),
"z": round(bb.zlen, 2),
},
"bbox_center_mm": {
"x": round((bb.xmin + bb.xmax) / 2, 2),
"y": round((bb.ymin + bb.ymax) / 2, 2),
"z": round((bb.zmin + bb.zmax) / 2, 2),
},
}
except Exception as exc:
logger.debug(f"_bbox_from_step_cadquery failed for {step_path}: {exc}")
return None
@celery_app.task(bind=True, name="app.tasks.step_tasks.process_step_file", queue="step_processing")
def process_step_file(self, cad_file_id: str):
"""Process a STEP file: extract objects, generate thumbnail, convert to glTF.
@@ -164,6 +229,42 @@ 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.
try:
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.config import settings as _cfg2
from app.models.cad_file import CadFile as _CadFile2
_sync_url2 = _cfg2.database_url.replace("+asyncpg", "")
_eng2 = create_engine(_sync_url2)
with Session(_eng2) as _sess2:
_cad2 = _sess2.get(_CadFile2, cad_file_id)
_step_path = _cad2.stored_path if _cad2 else None
_eng2.dispose()
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)
if bbox_data:
_eng2 = create_engine(_sync_url2)
with Session(_eng2) as _sess2:
_cad2 = _sess2.get(_CadFile2, cad_file_id)
if _cad2:
_cad2.mesh_attributes = {**( _cad2.mesh_attributes or {}), **bbox_data}
_sess2.commit()
dims = bbox_data["dimensions_mm"]
logger.info(
f"bbox for {cad_file_id}: "
f"{dims['x']}×{dims['y']}×{dims['z']} mm"
)
_eng2.dispose()
except Exception:
logger.exception(f"bbox extraction failed for {cad_file_id} (non-fatal)")
# Auto-populate materials now that parsed_objects are available
try:
_auto_populate_materials_for_cad(cad_file_id)
@@ -195,6 +296,52 @@ def render_step_thumbnail(self, cad_file_id: str):
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
@celery_app.task(name="app.tasks.step_tasks.reextract_cad_metadata", queue="thumbnail_rendering")
def reextract_cad_metadata(cad_file_id: str):
"""Re-extract bounding-box dimensions for an already-completed CAD file.
Uses cadquery (available in render-worker) to compute dimensions_mm.
Updates mesh_attributes without changing processing_status or re-rendering.
Safe to run on completed files.
"""
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
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.warning(f"reextract_cad_metadata: file not found {cad_file_id}")
eng.dispose()
return
step_path = cad_file.stored_path
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)
if patch:
with Session(eng) as session:
cad_file = session.get(CadFile, cad_file_id)
if cad_file:
cad_file.mesh_attributes = {**(cad_file.mesh_attributes or {}), **patch}
session.commit()
dims = patch["dimensions_mm"]
logger.info(
f"reextract_cad_metadata: {cad_file_id}"
f"{dims['x']}×{dims['y']}×{dims['z']} mm"
)
else:
logger.warning(f"reextract_cad_metadata: no bbox data for {cad_file_id}")
except Exception as exc:
logger.error(f"reextract_cad_metadata failed for {cad_file_id}: {exc}")
finally:
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."""
@@ -267,6 +414,15 @@ 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
# Read 3D export settings
from sqlalchemy import text as _text
_scale = float(session.execute(_text(
"SELECT value FROM system_settings WHERE key='gltf_scale_factor'"
)).scalar() or "0.001")
_smooth = (session.execute(_text(
"SELECT value FROM system_settings WHERE key='gltf_smooth_normals'"
)).scalar() or "true") == "true"
eng.dispose()
log_task_event(self.request.id, f"Starting generate_gltf_geometry_task: cad_file={cad_file_id}", "info")
@@ -280,7 +436,25 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
output_path = step.parent / f"{step.stem}_geometry.glb"
try:
import trimesh
import trimesh as _trimesh
def _process_mesh(m):
m.apply_scale(_scale)
if _smooth:
try:
_trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5)
except Exception:
pass # non-critical
mesh = trimesh.load(str(stl_path))
if hasattr(mesh, 'geometry'):
# trimesh.Scene with multiple sub-meshes
for sub in mesh.geometry.values():
_process_mesh(sub)
else:
_process_mesh(mesh)
mesh.export(str(output_path))
log_task_event(self.request.id, f"Completed successfully: {output_path.name}", "done")
logger.info("generate_gltf_geometry_task: exported %s", output_path.name)
@@ -295,12 +469,17 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
async def _store():
from app.database import AsyncSessionLocal
from app.domains.media.models import MediaAsset, MediaAssetType
from app.config import settings as _cfg
async with AsyncSessionLocal() as db:
import uuid
_key = str(output_path)
_prefix = str(_cfg.upload_dir).rstrip("/") + "/"
if _key.startswith(_prefix):
_key = _key[len(_prefix):]
asset = MediaAsset(
cad_file_id=uuid.UUID(cad_file_id),
asset_type=MediaAssetType.gltf_geometry,
storage_key=str(output_path),
storage_key=_key,
mime_type="model/gltf-binary",
file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
)
@@ -648,13 +827,18 @@ def render_order_line_task(self, order_line_id: str):
# Create MediaAsset so the render appears in the Media Browser
try:
from app.domains.media.models import MediaAsset, MediaAssetType as MAT
from app.config import settings as _cfg2
_ext = str(output_path).rsplit(".", 1)[-1].lower() if "." in str(output_path) else "bin"
_mime = "video/mp4" if _ext in ("mp4", "webm") else ("image/jpeg" if _ext in ("jpg", "jpeg") else "image/png")
_is_anim = bool(line.output_type and line.output_type.is_animation)
_at = MAT.turntable if _is_anim else MAT.still
# Extension determines type — poster frames (.jpg/.png) from animations are still stills
_at = MAT.turntable if _ext in ("mp4", "webm") else MAT.still
_tenant_id = line.product.cad_file.tenant_id if (line.product and line.product.cad_file) else None
# Normalize storage_key to relative path
_raw_key = str(output_path)
_upload_prefix = str(_cfg2.upload_dir).rstrip("/") + "/"
_norm_key = _raw_key[len(_upload_prefix):] if _raw_key.startswith(_upload_prefix) else _raw_key
_existing = session.execute(
select(MediaAsset.id).where(MediaAsset.storage_key == output_path).limit(1)
select(MediaAsset.id).where(MediaAsset.storage_key == _norm_key).limit(1)
).scalar_one_or_none()
if not _existing:
_asset = MediaAsset(
@@ -662,7 +846,7 @@ def render_order_line_task(self, order_line_id: str):
order_line_id=line.id,
product_id=line.product_id,
asset_type=_at,
storage_key=output_path,
storage_key=_norm_key,
mime_type=_mime,
)
session.add(_asset)