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