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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user