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
+89 -6
View File
@@ -41,6 +41,14 @@ SETTINGS_DEFAULTS: dict[str, str] = {
"smtp_user": "",
"smtp_password": "",
"smtp_from_address": "",
# 3D viewer / glTF export settings
"gltf_scale_factor": "0.001",
"gltf_smooth_normals": "true",
"viewer_max_distance": "50",
"viewer_min_distance": "0.001",
"gltf_material_quality": "pbr_colors",
"gltf_pbr_roughness": "0.4",
"gltf_pbr_metallic": "0.6",
}
@@ -63,6 +71,13 @@ class SettingsOut(BaseModel):
smtp_user: str = ""
smtp_password: str = ""
smtp_from_address: str = ""
gltf_scale_factor: float = 0.001
gltf_smooth_normals: bool = True
viewer_max_distance: float = 50.0
viewer_min_distance: float = 0.001
gltf_material_quality: str = "pbr_colors"
gltf_pbr_roughness: float = 0.4
gltf_pbr_metallic: float = 0.6
class SettingsUpdate(BaseModel):
@@ -84,6 +99,13 @@ class SettingsUpdate(BaseModel):
smtp_user: str | None = None
smtp_password: str | None = None
smtp_from_address: str | None = None
gltf_scale_factor: float | None = None
gltf_smooth_normals: bool | None = None
viewer_max_distance: float | None = None
viewer_min_distance: float | None = None
gltf_material_quality: str | None = None
gltf_pbr_roughness: float | None = None
gltf_pbr_metallic: float | None = None
@router.get("/users", response_model=list[UserOut])
@@ -191,6 +213,13 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
smtp_user=raw.get("smtp_user", ""),
smtp_password=raw.get("smtp_password", ""),
smtp_from_address=raw.get("smtp_from_address", ""),
gltf_scale_factor=float(raw.get("gltf_scale_factor", "0.001")),
gltf_smooth_normals=raw.get("gltf_smooth_normals", "true") == "true",
viewer_max_distance=float(raw.get("viewer_max_distance", "50")),
viewer_min_distance=float(raw.get("viewer_min_distance", "0.001")),
gltf_material_quality=raw.get("gltf_material_quality", "pbr_colors"),
gltf_pbr_roughness=float(raw.get("gltf_pbr_roughness", "0.4")),
gltf_pbr_metallic=float(raw.get("gltf_pbr_metallic", "0.6")),
)
@@ -285,6 +314,20 @@ async def update_settings(
updates["smtp_password"] = body.smtp_password
if body.smtp_from_address is not None:
updates["smtp_from_address"] = body.smtp_from_address
if body.gltf_scale_factor is not None:
updates["gltf_scale_factor"] = str(body.gltf_scale_factor)
if body.gltf_smooth_normals is not None:
updates["gltf_smooth_normals"] = "true" if body.gltf_smooth_normals else "false"
if body.viewer_max_distance is not None:
updates["viewer_max_distance"] = str(body.viewer_max_distance)
if body.viewer_min_distance is not None:
updates["viewer_min_distance"] = str(body.viewer_min_distance)
if body.gltf_material_quality is not None:
updates["gltf_material_quality"] = body.gltf_material_quality
if body.gltf_pbr_roughness is not None:
updates["gltf_pbr_roughness"] = str(body.gltf_pbr_roughness)
if body.gltf_pbr_metallic is not None:
updates["gltf_pbr_metallic"] = str(body.gltf_pbr_metallic)
for k, v in updates.items():
await _save_setting(db, k, v)
@@ -368,6 +411,33 @@ async def regenerate_thumbnails(
return {"queued": queued, "message": f"Re-queued {queued} CAD file(s) for thumbnail regeneration"}
@router.post("/settings/reextract-metadata", status_code=status.HTTP_202_ACCEPTED)
async def reextract_all_metadata(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Re-extract OCC metadata (dimensions, sharp edges) for all completed CAD files.
Updates mesh_attributes without re-rendering thumbnails or changing processing status.
Use this after deploying bbox/edge extraction improvements.
"""
result = await db.execute(
select(CadFile).where(
CadFile.processing_status == ProcessingStatus.completed,
CadFile.stored_path.isnot(None),
)
)
cad_files = result.scalars().all()
from app.tasks.step_tasks import reextract_cad_metadata
queued = 0
for cad_file in cad_files:
reextract_cad_metadata.delay(str(cad_file.id))
queued += 1
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(
admin: User = Depends(require_admin),
@@ -482,15 +552,25 @@ async def import_existing_media_assets(
created = 0
skipped = 0
from app.config import settings as _app_settings
def _normalize_key(path: str) -> str:
"""Strip UPLOAD_DIR prefix to store relative storage keys."""
key = str(path)
prefix = str(_app_settings.upload_dir).rstrip("/") + "/"
return key[len(prefix):] if key.startswith(prefix) else key
# 1. CadFiles with thumbnail_path
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
cad_result = await db.execute(
text("SELECT id, thumbnail_path FROM cad_files WHERE thumbnail_path IS NOT NULL AND processing_status = 'completed'")
)
for row in cad_result.fetchall():
cad_id, thumb_path = row
norm_key = _normalize_key(str(thumb_path))
# De-dup check
existing = await db.execute(
select(MediaAsset.id).where(MediaAsset.storage_key == thumb_path).limit(1)
select(MediaAsset.id).where(MediaAsset.storage_key == norm_key).limit(1)
)
if existing.scalar_one_or_none():
skipped += 1
@@ -500,13 +580,14 @@ async def import_existing_media_assets(
asset = MediaAsset(
cad_file_id=uuid.UUID(str(cad_id)),
asset_type=MediaAssetType.thumbnail,
storage_key=str(thumb_path),
storage_key=norm_key,
mime_type=mime,
)
db.add(asset)
created += 1
# 2. OrderLines with result_path
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
ol_result = await db.execute(
text("""
SELECT ol.id, ol.result_path, ol.product_id, COALESCE(ot.is_animation, false) as is_animation
@@ -516,9 +597,10 @@ async def import_existing_media_assets(
""")
)
for row in ol_result.fetchall():
ol_id, result_path, product_id, is_animation = row
ol_id, result_path, product_id, _is_animation = row
norm_key = _normalize_key(str(result_path))
existing = await db.execute(
select(MediaAsset.id).where(MediaAsset.storage_key == result_path).limit(1)
select(MediaAsset.id).where(MediaAsset.storage_key == norm_key).limit(1)
)
if existing.scalar_one_or_none():
skipped += 1
@@ -528,13 +610,14 @@ async def import_existing_media_assets(
mime = "video/mp4"
asset_type = MediaAssetType.turntable
else:
# Extension determines type — poster frames (.jpg/.png) are always stills
mime = "image/png" if ext.endswith(".png") else "image/jpeg"
asset_type = MediaAssetType.turntable if is_animation else MediaAssetType.still
asset_type = MediaAssetType.still
asset = MediaAsset(
order_line_id=uuid.UUID(str(ol_id)),
product_id=uuid.UUID(str(product_id)) if product_id else None,
asset_type=asset_type,
storage_key=str(result_path),
storage_key=norm_key,
mime_type=mime,
)
db.add(asset)