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)
|
||||
|
||||
@@ -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}"},
|
||||
)
|
||||
|
||||
@@ -75,6 +75,7 @@ def _product_out(product: Product, priority: list[str] | None = None) -> Product
|
||||
out.thumbnail_url = product.thumbnail_url
|
||||
out.processing_status = product.processing_status
|
||||
out.cad_parsed_objects = product.cad_parsed_objects
|
||||
out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None
|
||||
out.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
|
||||
out.stl_cached = _stl_cached_qualities(product)
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user