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
+149
View File
@@ -180,6 +180,9 @@ async def get_thumbnail(
db: AsyncSession = Depends(get_db),
):
"""Serve the thumbnail image for a CAD file (no auth — UUID is opaque enough)."""
from sqlalchemy import text
# Bypass RLS for this public endpoint (cad_files has tenant RLS but thumbnails are public)
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
cad = await _get_cad_file(id, db)
if not cad.thumbnail_path:
@@ -196,6 +199,7 @@ async def get_thumbnail(
path=str(thumb_path),
media_type=media_type,
filename=f"{id}{ext}",
headers={"Cache-Control": "max-age=3600, public"},
)
@@ -390,3 +394,148 @@ async def regenerate_thumbnail(
"status": "queued",
"task_id": task_id,
}
@router.get("/{id}/export-gltf-colored")
async def export_gltf_colored(
id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Export a GLB with PBR colors from part_colors (material alias mapping).
Loads per-part STLs from the low-quality parts cache directory and applies
PBR materials based on the product's cad_part_materials color assignments.
Falls back to the combined STL with a single grey material.
"""
from fastapi.responses import Response
from sqlalchemy import text, select
import trimesh
import io
if user.role.value not in ("admin", "project_manager"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
# Bypass RLS for cad_files + products
await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))
cad = await _get_cad_file(id, db)
if not cad.stored_path:
raise HTTPException(404, detail="STEP file not uploaded")
step_path = Path(cad.stored_path)
stl_path = step_path.parent / f"{step_path.stem}_low.stl"
parts_dir = step_path.parent / f"{step_path.stem}_low_parts"
if not stl_path.exists():
raise HTTPException(404, detail="STL cache not found. Trigger a render first.")
# Load settings
from app.models.system_setting import SystemSetting
settings_result = await db.execute(
select(SystemSetting.key, SystemSetting.value).where(
SystemSetting.key.in_([
"gltf_scale_factor", "gltf_smooth_normals",
"gltf_pbr_roughness", "gltf_pbr_metallic",
])
)
)
raw_settings = {k: v for k, v in settings_result.all()}
scale = float(raw_settings.get("gltf_scale_factor", "0.001"))
smooth = raw_settings.get("gltf_smooth_normals", "true") == "true"
roughness = float(raw_settings.get("gltf_pbr_roughness", "0.4"))
metallic = float(raw_settings.get("gltf_pbr_metallic", "0.6"))
# Load part colors from product
from app.domains.products.models import Product
part_colors: dict[str, str] = {}
if cad.id:
prod_result = await db.execute(
select(Product).where(Product.cad_file_id == cad.id).limit(1)
)
product = prod_result.scalar_one_or_none()
if product and product.cad_part_materials:
for entry in product.cad_part_materials:
part_name = entry.get("part_name") or entry.get("name", "")
hex_color = entry.get("hex_color") or entry.get("color", "")
if part_name and hex_color:
part_colors[part_name] = hex_color
def _hex_to_rgba(h: str) -> list:
h = h.lstrip("#")
if len(h) < 6:
return [0.7, 0.7, 0.7, 1.0]
try:
return [int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4)] + [1.0]
except Exception:
return [0.7, 0.7, 0.7, 1.0]
def _make_material(hex_color: str | None = None):
rgba = _hex_to_rgba(hex_color) if hex_color else [0.7, 0.7, 0.7, 1.0]
return trimesh.visual.material.PBRMaterial(
baseColorFactor=rgba,
roughnessFactor=roughness,
metallicFactor=metallic,
)
def _apply_mesh(mesh, color=None):
mesh.apply_scale(scale)
if smooth:
try:
trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)
except Exception:
pass
mesh.visual = trimesh.visual.TextureVisuals(material=_make_material(color))
return mesh
# Try per-part STLs first
scene = trimesh.Scene()
used_parts = False
if parts_dir.exists() and part_colors:
for part_name, hex_color in part_colors.items():
# Sanitize part name for filesystem
safe_name = part_name.replace("/", "_").replace("\\", "_")
part_stl = parts_dir / f"{safe_name}.stl"
if not part_stl.exists():
# Try lowercase / partial match
candidates = list(parts_dir.glob(f"{safe_name}*.stl"))
if not candidates:
candidates = list(parts_dir.glob("*.stl"))
candidates = [c for c in candidates if safe_name.lower() in c.stem.lower()]
if candidates:
part_stl = candidates[0]
else:
continue
try:
m = trimesh.load(str(part_stl), force="mesh")
_apply_mesh(m, hex_color)
scene.add_geometry(m, geom_name=part_name)
used_parts = True
except Exception:
pass
if not used_parts:
# Fallback: combined STL, single color
combined = trimesh.load(str(stl_path))
if hasattr(combined, 'geometry'):
for name, m in combined.geometry.items():
_apply_mesh(m, next(iter(part_colors.values()), None))
scene.add_geometry(m, geom_name=name)
else:
_apply_mesh(combined, next(iter(part_colors.values()), None))
scene.add_geometry(combined)
# Export to bytes
buf = io.BytesIO()
scene.export(buf, file_type="glb")
glb_bytes = buf.getvalue()
original_stem = Path(cad.original_name or "model").stem
filename = f"{original_stem}_colored.glb"
return Response(
content=glb_bytes,
media_type="model/gltf-binary",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)