refactor: replace STL intermediary with OCC-native STEP→GLB pipeline
- export_step_to_gltf.py: STEP→GLB via RWGltf_CafWriter + BRepBuilderAPI_Transform (mm→m pre-scaling, XCAFDoc_ShapeTool.GetComponents_s static method) - Blender scripts (blender_render.py, still_render.py, turntable_render.py, export_gltf.py, export_blend.py): import GLB instead of STL, remove _scale_mm_to_m - step_tasks.py: add generate_gltf_production_task, remove generate_stl_cache, replace _bbox_from_stl with _bbox_from_glb (trimesh), auto-queue geometry GLB after thumbnail render - render_blender.py: replace _stl_from_cache_or_convert with _glb_from_step, remove convert_step_to_stl and export_per_part_stls - domains/rendering/tasks.py: update render_turntable_task, export_gltf/blend tasks to use GLB instead of STL - cad.py: remove STL download/generate endpoints, add generate-gltf-production - admin.py: generate-missing-stls → generate-missing-geometry-glbs - Frontend: replace STL cache UI with GLB generate buttons, remove stl_cached field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -438,30 +438,36 @@ async def reextract_all_metadata(
|
||||
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(
|
||||
@router.post("/settings/generate-missing-geometry-glbs", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_missing_geometry_glbs(
|
||||
admin: User = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue STL generation for every quality missing from each completed CAD file."""
|
||||
from pathlib import Path as _Path
|
||||
"""Queue geometry GLB generation for every completed CAD file that has no gltf_geometry MediaAsset."""
|
||||
import uuid as _uuid
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
|
||||
result = await db.execute(
|
||||
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
|
||||
)
|
||||
cad_files = result.scalars().all()
|
||||
|
||||
from app.tasks.step_tasks import generate_stl_cache
|
||||
# Bulk-fetch existing gltf_geometry assets
|
||||
existing_result = await db.execute(
|
||||
select(MediaAsset.cad_file_id).where(MediaAsset.asset_type == MediaAssetType.gltf_geometry)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing_result.all()}
|
||||
|
||||
from app.tasks.step_tasks import generate_gltf_geometry_task
|
||||
queued = 0
|
||||
for cad_file in cad_files:
|
||||
if not cad_file.stored_path:
|
||||
continue
|
||||
step = _Path(cad_file.stored_path)
|
||||
for quality in ("low", "high"):
|
||||
if not (step.parent / f"{step.stem}_{quality}.stl").exists():
|
||||
generate_stl_cache.delay(str(cad_file.id), quality)
|
||||
queued += 1
|
||||
if cad_file.id not in existing_ids:
|
||||
generate_gltf_geometry_task.delay(str(cad_file.id))
|
||||
queued += 1
|
||||
|
||||
return {"queued": queued, "message": f"Queued {queued} missing STL generation task(s)"}
|
||||
return {"queued": queued, "message": f"Queued {queued} missing geometry GLB task(s)"}
|
||||
|
||||
|
||||
@router.post("/settings/seed-workflows", status_code=status.HTTP_200_OK)
|
||||
|
||||
+25
-216
@@ -262,78 +262,16 @@ async def get_objects(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{id}/stl/{quality}")
|
||||
async def download_stl(
|
||||
id: uuid.UUID,
|
||||
quality: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download the cached STL for a CAD file with a human-readable filename.
|
||||
|
||||
The STL is cached next to the STEP file on first render.
|
||||
quality must be 'low' or 'high'.
|
||||
"""
|
||||
if quality not in ("low", "high"):
|
||||
raise HTTPException(400, detail="quality must be 'low' or 'high'")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
step_path = Path(cad.stored_path)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{quality}.stl"
|
||||
|
||||
if not stl_path.exists():
|
||||
raise HTTPException(
|
||||
404,
|
||||
detail=f"STL cache not found for quality '{quality}'. Trigger a render first to generate it.",
|
||||
)
|
||||
|
||||
original_stem = Path(cad.original_name or "model").stem
|
||||
filename = f"{original_stem}_{quality}.stl"
|
||||
|
||||
return FileResponse(
|
||||
path=str(stl_path),
|
||||
media_type="application/octet-stream",
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{id}/generate-stl/{quality}", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_stl(
|
||||
id: uuid.UUID,
|
||||
quality: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue STL generation for the given quality without triggering a full render."""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
if quality not in ("low", "high"):
|
||||
raise HTTPException(status_code=400, detail="quality must be 'low' or 'high'")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
from app.tasks.step_tasks import generate_stl_cache
|
||||
task = generate_stl_cache.delay(str(id), quality)
|
||||
return {"status": "queued", "task_id": task.id, "quality": quality}
|
||||
|
||||
|
||||
@router.post("/{id}/generate-gltf-geometry", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_gltf_geometry(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue GLB geometry export from the existing STL cache (trimesh, no Blender).
|
||||
"""Queue GLB geometry export directly from STEP via OCC (no STL required).
|
||||
|
||||
Stores the result as a MediaAsset with asset_type='gltf_geometry'.
|
||||
The STL low-quality cache must already exist (run a thumbnail render first).
|
||||
Uses export_step_to_gltf.py (OCP/pythonocc) — no Blender needed.
|
||||
"""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
@@ -342,20 +280,34 @@ async def generate_gltf_geometry(
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
step_path = Path(cad.stored_path)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="STL low-quality cache not found. Trigger a render first to generate it.",
|
||||
)
|
||||
|
||||
# Queue as a thumbnail_rendering task (trimesh available in render-worker)
|
||||
from app.tasks.step_tasks import generate_gltf_geometry_task
|
||||
task = generate_gltf_geometry_task.delay(str(id))
|
||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
||||
|
||||
|
||||
@router.post("/{id}/generate-gltf-production", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def generate_gltf_production(
|
||||
id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Queue production GLB export (Blender + PBR materials) from a geometry GLB.
|
||||
|
||||
Requires a gltf_geometry MediaAsset to already exist (run generate-gltf-geometry first).
|
||||
Stores result as a MediaAsset with asset_type='gltf_production'.
|
||||
"""
|
||||
if user.role.value not in ("admin", "project_manager"):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
||||
cad = await _get_cad_file(id, db)
|
||||
if not cad.stored_path:
|
||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
||||
|
||||
from app.tasks.step_tasks import generate_gltf_production_task
|
||||
task = generate_gltf_production_task.delay(str(id))
|
||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{id}/regenerate-thumbnail",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
@@ -396,146 +348,3 @@ async def regenerate_thumbnail(
|
||||
}
|
||||
|
||||
|
||||
@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}"},
|
||||
)
|
||||
|
||||
@@ -77,20 +77,9 @@ def _product_out(product: Product, priority: list[str] | None = None) -> Product
|
||||
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
|
||||
|
||||
|
||||
def _stl_cached_qualities(product: Product) -> list[str]:
|
||||
"""Return list of STL qualities that are cached on disk for this product."""
|
||||
from pathlib import Path as _Path
|
||||
cad = product.cad_file
|
||||
if not cad or not cad.stored_path:
|
||||
return []
|
||||
step = _Path(cad.stored_path)
|
||||
return [q for q in ("low", "high") if (step.parent / f"{step.stem}_{q}.stl").exists()]
|
||||
|
||||
|
||||
async def _load_thumbnail_priority(db: AsyncSession) -> list[str]:
|
||||
"""Read product_thumbnail_priority from system_settings.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user