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