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:
@@ -27,6 +27,37 @@
|
||||
**Lösung**: `_normalize_key()` Helper: strippt `UPLOAD_DIR`-Prefix. In `download_asset` Legacy-Remapping für alte Pfade als Fallback behalten. Neue Assets immer relativ speichern.
|
||||
**Für künftige Projekte**: `storage_key` immer relativ zu `UPLOAD_DIR` → `candidate = Path(settings.upload_dir) / key`. Absolute Pfade nie in die DB schreiben.
|
||||
|
||||
### 2026-03-07 | OCP | `RWMesh_CoordinateSystemConverter` nicht als Python-Binding verfügbar (OCP 7.8.1.1)
|
||||
**Problem**: `writer.ChangeCoordinateSystemConverter()` wirft `TypeError: Unregistered type : RWMesh_CoordinateSystemConverter` — der C++-Typ ist nicht in OCP-Python-Bindings registriert.
|
||||
**Lösung**: Shapes vor dem Export mit `BRepBuilderAPI_Transform` um Faktor 0.001 skalieren (mm→m). Dann `RWGltf_CafWriter` direkt ohne Koordinatensystem-Konverter aufrufen.
|
||||
```python
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetScaleFactor(0.001)
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
label = free_labels.Value(i)
|
||||
scaled = BRepBuilderAPI_Transform(shape_tool.GetShape_s(label), trsf, True).Shape()
|
||||
shape_tool.SetShape(label, scaled)
|
||||
```
|
||||
**Für künftige Projekte**: Immer OCP-Methoden-Verfügbarkeit mit `hasattr()` oder `dir()` testen bevor man sie aufruft.
|
||||
|
||||
### 2026-03-07 | OCP | `XCAFDoc_ShapeTool.GetComponents` → `GetComponents_s` (static method suffix)
|
||||
**Problem**: `shape_tool.GetComponents(label, seq)` → `AttributeError: 'XCAFDoc_ShapeTool' has no attribute 'GetComponents'`
|
||||
**Lösung**: In OCP sind alle XCAF static-Methoden mit `_s`-Suffix: `XCAFDoc_ShapeTool.GetComponents_s(label, seq)`. Gilt für alle `XCAFDoc_*`-Klassen.
|
||||
|
||||
### 2026-03-07 | Pipeline | OCC-native STEP→GLB ersetzt STL-Intermediary komplett
|
||||
**Problem**: Pipeline nutzte STL als Zwischenformat (STEP→STL via cadquery, STL→Blender). STL verliert Materialnamen, Farben, OCC-Topologie. Zwei parallele Konvertierungen (cadquery STL + Blender Import) = doppelter Aufwand. STL-Cache auf Disk = Dateiflut.
|
||||
**Lösung**: `export_step_to_gltf.py` nutzt OCC `RWGltf_CafWriter` direkt: STEP→GLB in einem Schritt. Koordinatensystem-Konvertierung (Z-up→Y-up) + mm→m Skalierung übernimmt `conv.SetInputLengthUnit(1e-3)`. Blender importiert GLB nativ via `bpy.ops.import_scene.gltf()` — kein `_scale_mm_to_m` mehr nötig.
|
||||
**OCC-API-Gotcha**: Projekt nutzt `OCP` (cadquery's pythonocc-Bindings), NICHT `OCC.Core`. Statische Methoden haben `_s`-Suffix: `XCAFApp_Application.GetApplication_s()`, `XCAFDoc_DocumentTool.ShapeTool_s()`.
|
||||
**Für künftige Projekte**: `RWGltf_CafWriter` + `STEPCAFControl_Reader` sind die kanonische STEP→GLB Pipeline. `BRepMesh_IncrementalMesh` tesselliert vor dem Export. `XCAFDoc_ColorTool.SetColor(label, color, XCAFDoc_ColorSurf)` überträgt Farben in GLB-Materialien.
|
||||
|
||||
### 2026-03-07 | Celery | generate_gltf_geometry_task als Subprocess — kein bpy-Import-Konflikt
|
||||
**Problem**: OCP und bpy können nicht im selben Python-Prozess koexistieren — OCP lädt native C++-Bibliotheken die mit Blenders internen Versionen kollidieren. Der render-worker-Container hat BEIDE (cadquery + Blender).
|
||||
**Lösung**: `export_step_to_gltf.py` via `sys.executable` subprocess aus Celery-Task heraus starten. So läuft OCC in isoliertem Prozess, kein Import-Pollution. Pattern: `subprocess.run([sys.executable, script, "--arg", val], timeout=120)`.
|
||||
|
||||
### 2026-03-07 | Bbox | GLB statt STL für Bounding-Box Extraktion
|
||||
**Problem**: `_bbox_from_stl()` nutzte numpy binary parsing des STL-Headers (schnell, aber STL existiert nicht mehr nach Pipeline-Umbau).
|
||||
**Lösung**: `_bbox_from_glb()` mit trimesh: `scene = trimesh.load(glb_path, force="scene"); bounds = scene.bounds`. GLB ist in Metern → `* 1000` für mm. Fallback auf `_bbox_from_step_cadquery()` bleibt erhalten.
|
||||
|
||||
### 2026-03-07 | Workflow | Turntable-Workflow brauchte step_path zur Laufzeit
|
||||
**Problem**: `WorkflowDefinition.config` ist statisch (JSON) — enthält keine produktspezifischen Pfade. `_build_turntable()` erwartet `step_path` + `output_dir` in params → `ValueError` bei Workflow-Dispatch.
|
||||
**Lösung**: `dispatch_render_with_workflow()` löst `step_path` + `output_dir` aus dem `OrderLine → Product → CadFile` Graph auf und injiziert sie in params vor `dispatch_workflow()`.
|
||||
|
||||
@@ -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)
|
||||
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.
|
||||
|
||||
|
||||
@@ -59,7 +59,6 @@ class ProductOut(BaseModel):
|
||||
thumbnail_url: str | None = None
|
||||
render_image_url: str | None = None
|
||||
processing_status: str | None = None
|
||||
stl_cached: list[str] = []
|
||||
cad_parsed_objects: list[str] | None = None
|
||||
cad_mesh_attributes: dict | None = None
|
||||
arbeitspaket: str | None = None
|
||||
|
||||
@@ -193,9 +193,8 @@ def render_turntable_task(
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from app.services.render_blender import (
|
||||
find_blender, convert_step_to_stl, export_per_part_stls
|
||||
)
|
||||
import sys
|
||||
from app.services.render_blender import find_blender
|
||||
|
||||
blender_bin = find_blender()
|
||||
if not blender_bin:
|
||||
@@ -208,27 +207,25 @@ def render_turntable_task(
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
turntable_script = scripts_dir / "turntable_render.py"
|
||||
|
||||
# STL conversion — try MinIO cache first, then convert locally
|
||||
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
try:
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
||||
step_hash = compute_step_hash(str(step))
|
||||
cached = check_stl_cache(step_hash, stl_quality)
|
||||
if cached:
|
||||
stl_path.write_bytes(cached)
|
||||
logger.info("STL restored from MinIO cache: %s", stl_path.name)
|
||||
else:
|
||||
convert_step_to_stl(step, stl_path, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc)
|
||||
convert_step_to_stl(step, stl_path, stl_quality)
|
||||
parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts"
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part export non-fatal: %s", exc)
|
||||
# GLB generation via OCC (replaces STL intermediary)
|
||||
linear_deflection = 0.3 if stl_quality == "low" else 0.05
|
||||
angular_deflection = 0.3 if stl_quality == "low" else 0.1
|
||||
glb_path = step.parent / f"{step.stem}_{stl_quality}.glb"
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||
occ_cmd = [
|
||||
sys.executable, str(occ_script),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(glb_path),
|
||||
"--linear_deflection", str(linear_deflection),
|
||||
"--angular_deflection", str(angular_deflection),
|
||||
]
|
||||
occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
||||
if occ_result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}"
|
||||
)
|
||||
logger.info("render_turntable_task: GLB generated: %s", glb_path.name)
|
||||
|
||||
# Build turntable render arguments
|
||||
frames_dir = out_dir / "frames"
|
||||
@@ -238,7 +235,7 @@ def render_turntable_task(
|
||||
blender_bin, "--background",
|
||||
"--python", str(turntable_script),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(frames_dir),
|
||||
output_name,
|
||||
str(width), str(height),
|
||||
@@ -463,93 +460,40 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
|
||||
max_retries=1,
|
||||
)
|
||||
def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
"""Export a GLB from the STL cache via Blender subprocess (with trimesh fallback).
|
||||
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
|
||||
|
||||
Publishes a MediaAsset with asset_type='gltf_geometry' (no asset lib) or
|
||||
'gltf_production' (when an asset library is applied).
|
||||
Requires the STL low-quality cache to exist.
|
||||
Publishes a MediaAsset with asset_type='gltf_geometry'.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
|
||||
if not step_path_str:
|
||||
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
||||
|
||||
step = Path(step_path_str)
|
||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
raise RuntimeError(
|
||||
f"STL cache not found: {stl_path}. Run thumbnail generation first."
|
||||
)
|
||||
|
||||
output_path = step.parent / f"{step.stem}_geometry.glb"
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
export_script = scripts_dir / "export_gltf.py"
|
||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
if not occ_script.exists():
|
||||
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
|
||||
|
||||
asset_type = "gltf_geometry"
|
||||
|
||||
# Load sharp edge hints from mesh_attributes for UV seam marking
|
||||
sharp_edges_json = "[]"
|
||||
if cad_file_id:
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _load_mesh_attrs() -> list:
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.cad_file import CadFile as _CF
|
||||
from sqlalchemy import select as _sel
|
||||
async with AsyncSessionLocal() as _db:
|
||||
_res = await _db.execute(_sel(_CF).where(_CF.id == cad_file_id))
|
||||
_cad = _res.scalar_one_or_none()
|
||||
if _cad and _cad.mesh_attributes:
|
||||
return _cad.mesh_attributes.get("sharp_edge_midpoints") or []
|
||||
return []
|
||||
|
||||
_midpoints = _asyncio.get_event_loop().run_until_complete(_load_mesh_attrs())
|
||||
if _midpoints:
|
||||
sharp_edges_json = json.dumps(_midpoints)
|
||||
except Exception as _exc:
|
||||
logger.warning("Could not load sharp_edge_midpoints for %s: %s", cad_file_id, _exc)
|
||||
|
||||
if is_blender_available() and export_script.exists():
|
||||
blender_bin = find_blender()
|
||||
cmd = [
|
||||
blender_bin, "--background",
|
||||
"--python", str(export_script),
|
||||
"--",
|
||||
"--stl_path", str(stl_path),
|
||||
sys.executable, str(occ_script),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(output_path),
|
||||
"--asset_library_blend", "",
|
||||
"--material_map", json.dumps({}),
|
||||
"--sharp_edges_json", sharp_edges_json,
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
f"export_step_to_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
)
|
||||
publish_asset.delay(order_line_id, asset_type, str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name)
|
||||
return {"glb_path": str(output_path), "method": "blender"}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Blender GLB export failed for %s, falling back to trimesh: %s",
|
||||
order_line_id, exc,
|
||||
)
|
||||
|
||||
# Trimesh fallback
|
||||
try:
|
||||
import trimesh
|
||||
mesh = trimesh.load(str(stl_path))
|
||||
mesh.export(str(output_path))
|
||||
publish_asset.delay(order_line_id, asset_type, str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed via trimesh: %s", output_path.name)
|
||||
return {"glb_path": str(output_path), "method": "trimesh"}
|
||||
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
|
||||
logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name)
|
||||
return {"glb_path": str(output_path), "method": "occ"}
|
||||
except Exception as exc:
|
||||
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
|
||||
raise self.retry(exc=exc, countdown=15)
|
||||
@@ -576,9 +520,20 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
||||
|
||||
step = Path(step_path_str)
|
||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
||||
# Use geometry GLB as input (generate if missing)
|
||||
glb_path = step.parent / f"{step.stem}_geometry.glb"
|
||||
if not glb_path.exists():
|
||||
import subprocess as _sp
|
||||
import sys as _sys
|
||||
scripts_dir_tmp = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
occ_cmd = [
|
||||
_sys.executable, str(scripts_dir_tmp / "export_step_to_gltf.py"),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(glb_path),
|
||||
]
|
||||
occ_res = _sp.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
||||
if occ_res.returncode != 0:
|
||||
raise RuntimeError(f"GLB generation failed:\n{occ_res.stderr[-500:]}")
|
||||
|
||||
output_path = step.parent / f"{step.stem}_production.blend"
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
@@ -617,7 +572,7 @@ def export_blend_for_order_line_task(self, order_line_id: str) -> dict:
|
||||
blender_bin, "--background",
|
||||
"--python", str(export_script),
|
||||
"--",
|
||||
"--stl_path", str(stl_path),
|
||||
"--glb_path", str(glb_path),
|
||||
"--output_path", str(output_path),
|
||||
"--asset_library_blend", asset_lib_path,
|
||||
"--material_map", json.dumps(mat_map),
|
||||
@@ -670,7 +625,7 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
|
||||
if not product or not product.cad_file_id:
|
||||
return None, None, None
|
||||
cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_none()
|
||||
stl_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_low.stl") if cad else None
|
||||
glb_path = str(Path(cad.stored_path).parent / f"{Path(cad.stored_path).stem}_geometry.glb") if cad else None
|
||||
|
||||
# Resolve asset library blend path
|
||||
try:
|
||||
@@ -681,24 +636,24 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
|
||||
blend_path = None
|
||||
|
||||
mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])}
|
||||
return stl_path, blend_path, mat_map
|
||||
return glb_path, blend_path, mat_map
|
||||
|
||||
result = _inner()
|
||||
if result is None or result[0] is None:
|
||||
logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id)
|
||||
return {"status": "skipped"}
|
||||
|
||||
stl_path, blend_path, mat_map = result
|
||||
if not stl_path or not Path(stl_path).exists():
|
||||
logger.warning("STL not found for %s", order_line_id)
|
||||
return {"status": "skipped", "reason": "stl_not_found"}
|
||||
glb_path, blend_path, mat_map = result
|
||||
if not glb_path or not Path(glb_path).exists():
|
||||
logger.warning("Geometry GLB not found for %s", order_line_id)
|
||||
return {"status": "skipped", "reason": "glb_not_found"}
|
||||
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script = scripts_dir / "asset_library.py"
|
||||
|
||||
cmd = [
|
||||
blender_bin, "--background", "--python", str(script), "--",
|
||||
"--stl_path", stl_path,
|
||||
"--glb_path", glb_path,
|
||||
"--asset_library_blend", blend_path or "",
|
||||
"--material_map", json.dumps(mat_map),
|
||||
]
|
||||
|
||||
@@ -17,24 +17,39 @@ logger = logging.getLogger(__name__)
|
||||
MIN_BLENDER_VERSION = (5, 0, 1)
|
||||
|
||||
|
||||
def _stl_from_cache_or_convert(step_path: Path, stl_path: Path, quality: str) -> None:
|
||||
"""Try MinIO cache first, then fall back to local STEP→STL conversion."""
|
||||
# MinIO cache check (non-fatal — cache miss just means we convert normally)
|
||||
try:
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
||||
step_hash = compute_step_hash(str(step_path))
|
||||
cached_bytes = check_stl_cache(step_hash, quality)
|
||||
if cached_bytes:
|
||||
stl_path.write_bytes(cached_bytes)
|
||||
logger.info("STL restored from MinIO cache: %s (%d KB)", stl_path.name, len(cached_bytes) // 1024)
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.warning("MinIO cache check failed (non-fatal): %s", exc)
|
||||
def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None:
|
||||
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed).
|
||||
|
||||
# Local conversion
|
||||
from app.services.step_processor import convert_step_to_stl
|
||||
logger.info("STL cache miss — converting: %s", step_path.name)
|
||||
convert_step_to_stl(step_path, stl_path, quality)
|
||||
quality: "low" → coarser mesh (~0.3 mm deflection, fast)
|
||||
"high" → finer mesh (~0.05 mm deflection, slower)
|
||||
"""
|
||||
import subprocess
|
||||
import sys as _sys
|
||||
|
||||
linear_deflection = 0.3 if quality == "low" else 0.05
|
||||
angular_deflection = 0.3 if quality == "low" else 0.1
|
||||
|
||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
cmd = [
|
||||
_sys.executable, str(script_path),
|
||||
"--step_path", str(step_path),
|
||||
"--output_path", str(glb_path),
|
||||
"--linear_deflection", str(linear_deflection),
|
||||
"--angular_deflection", str(angular_deflection),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
for line in result.stdout.splitlines():
|
||||
logger.info("[occ-gltf] %s", line)
|
||||
for line in result.stderr.splitlines():
|
||||
logger.warning("[occ-gltf stderr] %s", line)
|
||||
if result.returncode != 0 or not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
|
||||
f"STDERR: {result.stderr[-1000:]}"
|
||||
)
|
||||
logger.info("GLB converted: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
|
||||
|
||||
def find_blender() -> str:
|
||||
@@ -51,127 +66,6 @@ def is_blender_available() -> bool:
|
||||
return bool(find_blender())
|
||||
|
||||
|
||||
def convert_step_to_stl(step_path: Path, stl_path: Path, quality: str = "low") -> None:
|
||||
"""Convert a STEP file to STL using cadquery.
|
||||
|
||||
Raises ImportError if cadquery is not installed (not available in backend
|
||||
container — only in render-worker container).
|
||||
"""
|
||||
import cadquery as cq # only available in render-worker
|
||||
|
||||
if quality == "high":
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.01, angularTolerance=0.02)
|
||||
else:
|
||||
shape = cq.importers.importStep(str(step_path))
|
||||
cq.exporters.export(shape, str(stl_path), tolerance=0.3, angularTolerance=0.3)
|
||||
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
raise RuntimeError("cadquery produced empty STL")
|
||||
|
||||
|
||||
def export_per_part_stls(step_path: Path, parts_dir: Path, quality: str = "low") -> list:
|
||||
"""Export one STL per named STEP leaf shape using OCP XCAF.
|
||||
|
||||
Returns the manifest list (may be empty on failure — non-fatal).
|
||||
"""
|
||||
tol = 0.01 if quality == "high" else 0.3
|
||||
angular_tol = 0.05 if quality == "high" else 0.3
|
||||
|
||||
try:
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool, XCAFDoc_ShapeTool
|
||||
from OCP.TDataStd import TDataStd_Name
|
||||
from OCP.TDF import TDF_Label as TDF_Label_cls, TDF_LabelSequence
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.TCollection import TCollection_ExtendedString
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
import cadquery as cq
|
||||
except ImportError as e:
|
||||
logger.warning("per-part export skipped (import error): %s", e)
|
||||
return []
|
||||
|
||||
app = XCAFApp_Application.GetApplication_s()
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("XmlOcaf"))
|
||||
app.InitDocument(doc)
|
||||
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
status = reader.ReadFile(str(step_path))
|
||||
if status != IFSelect_RetDone:
|
||||
logger.warning("XCAF reader failed with status %s", status)
|
||||
return []
|
||||
|
||||
if not reader.Transfer(doc):
|
||||
logger.warning("XCAF transfer failed")
|
||||
return []
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
name_id = TDataStd_Name.GetID_s()
|
||||
|
||||
leaves = []
|
||||
|
||||
def _get_label_name(label):
|
||||
name_attr = TDataStd_Name()
|
||||
if label.FindAttribute(name_id, name_attr):
|
||||
return name_attr.Get().ToExtString()
|
||||
return ""
|
||||
|
||||
def _collect_leaves(label):
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(label):
|
||||
components = TDF_LabelSequence()
|
||||
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||
for i in range(1, components.Length() + 1):
|
||||
comp_label = components.Value(i)
|
||||
if XCAFDoc_ShapeTool.IsReference_s(comp_label):
|
||||
ref_label = TDF_Label_cls()
|
||||
XCAFDoc_ShapeTool.GetReferredShape_s(comp_label, ref_label)
|
||||
comp_name = _get_label_name(comp_label)
|
||||
ref_name = _get_label_name(ref_label)
|
||||
name = ref_name or comp_name
|
||||
if XCAFDoc_ShapeTool.IsAssembly_s(ref_label):
|
||||
_collect_leaves(ref_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(ref_label):
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(comp_label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
else:
|
||||
_collect_leaves(comp_label)
|
||||
elif XCAFDoc_ShapeTool.IsSimpleShape_s(label):
|
||||
name = _get_label_name(label)
|
||||
shape = XCAFDoc_ShapeTool.GetShape_s(label)
|
||||
leaves.append((name or f"unnamed_{len(leaves)}", shape))
|
||||
|
||||
top_labels = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(top_labels)
|
||||
for i in range(1, top_labels.Length() + 1):
|
||||
_collect_leaves(top_labels.Value(i))
|
||||
|
||||
if not leaves:
|
||||
logger.warning("no leaf shapes found via XCAF")
|
||||
return []
|
||||
|
||||
parts_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = []
|
||||
|
||||
for idx, (name, shape) in enumerate(leaves):
|
||||
safe_name = name.replace("/", "_").replace("\\", "_").replace(" ", "_")
|
||||
filename = f"{idx:02d}_{safe_name}.stl"
|
||||
filepath = str(parts_dir / filename)
|
||||
try:
|
||||
cq_shape = cq.Shape(shape)
|
||||
cq_shape.exportStl(filepath, tolerance=tol, angularTolerance=angular_tol)
|
||||
manifest.append({"index": idx, "name": name, "file": filename})
|
||||
except Exception as e:
|
||||
logger.warning("failed to export part '%s': %s", name, e)
|
||||
|
||||
manifest_path = parts_dir / "manifest.json"
|
||||
with open(manifest_path, "w") as f:
|
||||
json.dump({"parts": manifest}, f, indent=2)
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def render_still(
|
||||
step_path: Path,
|
||||
output_path: Path,
|
||||
@@ -202,7 +96,7 @@ def render_still(
|
||||
denoising_use_gpu: str = "",
|
||||
mesh_attributes: dict | None = None,
|
||||
) -> dict:
|
||||
"""Convert STEP → STL (cadquery) → PNG (Blender subprocess).
|
||||
"""Convert STEP → GLB (OCC) → PNG (Blender subprocess).
|
||||
|
||||
Returns a dict with timing, sizes, engine_used, and log_lines.
|
||||
Raises RuntimeError on failure.
|
||||
@@ -215,7 +109,6 @@ def render_still(
|
||||
|
||||
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
|
||||
if not script_path.exists():
|
||||
# Fallback: look next to this file (development mode)
|
||||
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
|
||||
if alt.exists():
|
||||
script_path = alt
|
||||
@@ -224,24 +117,16 @@ def render_still(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# 1. STL conversion (cadquery)
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
||||
t_glb = time.monotonic()
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||
else:
|
||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
|
||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||
|
||||
# 2. Blender render
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -263,7 +148,7 @@ def render_still(
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(output_path),
|
||||
str(width), str(height),
|
||||
eng, str(samples), str(smooth_angle),
|
||||
@@ -332,22 +217,13 @@ def render_still(
|
||||
|
||||
render_duration_s = round(time.monotonic() - t_render, 2)
|
||||
|
||||
parts_count = 0
|
||||
manifest_file = parts_dir / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
try:
|
||||
data = json.loads(manifest_file.read_text())
|
||||
parts_count = len(data.get("parts", []))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
|
||||
"render_duration_s": render_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"stl_size_bytes": glb_size_bytes,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"parts_count": parts_count,
|
||||
"parts_count": 0,
|
||||
"engine_used": engine_used,
|
||||
"log_lines": log_lines,
|
||||
}
|
||||
@@ -407,24 +283,15 @@ def render_turntable_to_file(
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
# 1. STL conversion
|
||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
||||
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||
|
||||
t_stl = time.monotonic()
|
||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
||||
t_glb = time.monotonic()
|
||||
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||
else:
|
||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
||||
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step_path, parts_dir, stl_quality)
|
||||
except Exception as exc:
|
||||
logger.warning("per-part STL export failed (non-fatal): %s", exc)
|
||||
|
||||
stl_duration_s = round(time.monotonic() - t_stl, 2)
|
||||
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||
|
||||
# 2. Render frames with Blender
|
||||
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
|
||||
@@ -439,7 +306,7 @@ def render_turntable_to_file(
|
||||
"--background",
|
||||
"--python", str(script_path),
|
||||
"--",
|
||||
str(stl_path),
|
||||
str(glb_path),
|
||||
str(frames_dir),
|
||||
str(frame_count),
|
||||
"360", # degrees
|
||||
@@ -554,10 +421,10 @@ def render_turntable_to_file(
|
||||
|
||||
return {
|
||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
||||
"stl_duration_s": stl_duration_s,
|
||||
"stl_duration_s": glb_duration_s, # key kept for backward compat with DB render_log
|
||||
"render_duration_s": render_duration_s,
|
||||
"ffmpeg_duration_s": ffmpeg_duration_s,
|
||||
"stl_size_bytes": stl_size_bytes,
|
||||
"stl_size_bytes": 0,
|
||||
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||
"frame_count": len(frame_files),
|
||||
"engine_used": engine,
|
||||
|
||||
+208
-165
@@ -1,6 +1,5 @@
|
||||
"""Celery tasks for STEP file processing and thumbnail generation."""
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.core.task_logs import log_task_event
|
||||
@@ -8,44 +7,37 @@ from app.core.task_logs import log_task_event
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bbox_from_stl(stl_path: str) -> dict | None:
|
||||
"""Extract bounding box from a cached binary STL file.
|
||||
def _bbox_from_glb(glb_path: str) -> dict | None:
|
||||
"""Extract bounding box from a GLB file (meters → converted to mm).
|
||||
|
||||
Returns {"dimensions_mm": {x,y,z}, "bbox_center_mm": {x,y,z}} or None on failure.
|
||||
Reading vertex extremes from an existing STL is ~10-100× faster than re-parsing STEP.
|
||||
OCC GLB output is in meters; multiply by 1000 to get mm.
|
||||
"""
|
||||
try:
|
||||
import numpy as np
|
||||
p = Path(stl_path)
|
||||
if not p.exists() or p.stat().st_size < 84:
|
||||
import trimesh
|
||||
p = Path(glb_path)
|
||||
if not p.exists():
|
||||
return None
|
||||
with p.open("rb") as f:
|
||||
f.seek(80) # skip 80-byte header
|
||||
n = struct.unpack("<I", f.read(4))[0]
|
||||
if n == 0:
|
||||
scene = trimesh.load(str(p), force="scene")
|
||||
bounds = getattr(scene, "bounds", None)
|
||||
if bounds is None:
|
||||
return None
|
||||
raw = f.read(n * 50) # 50 bytes per triangle
|
||||
# Binary STL per-triangle layout: normal(12B) + v1(12B) + v2(12B) + v3(12B) + attr(2B) = 50B
|
||||
# Extract vertex bytes (columns 12..48 of each 50-byte row)
|
||||
arr = np.frombuffer(raw, dtype=np.uint8).reshape(n, 50)
|
||||
verts = np.frombuffer(arr[:, 12:48].tobytes(), dtype=np.float32).reshape(-1, 3)
|
||||
mins = verts.min(axis=0)
|
||||
maxs = verts.max(axis=0)
|
||||
mins, maxs = bounds
|
||||
dims = maxs - mins
|
||||
return {
|
||||
"dimensions_mm": {
|
||||
"x": round(float(dims[0]), 2),
|
||||
"y": round(float(dims[1]), 2),
|
||||
"z": round(float(dims[2]), 2),
|
||||
"x": round(float(dims[0]) * 1000, 2),
|
||||
"y": round(float(dims[1]) * 1000, 2),
|
||||
"z": round(float(dims[2]) * 1000, 2),
|
||||
},
|
||||
"bbox_center_mm": {
|
||||
"x": round(float((mins[0] + maxs[0]) / 2), 2),
|
||||
"y": round(float((mins[1] + maxs[1]) / 2), 2),
|
||||
"z": round(float((mins[2] + maxs[2]) / 2), 2),
|
||||
"x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2),
|
||||
"y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2),
|
||||
"z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2),
|
||||
},
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug(f"_bbox_from_stl failed for {stl_path}: {exc}")
|
||||
logger.debug(f"_bbox_from_glb failed for {glb_path}: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -229,9 +221,9 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
logger.error(f"Thumbnail render failed for {cad_file_id}: {exc}")
|
||||
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
||||
|
||||
# Extract bounding box from the STL that was just cached by the renderer.
|
||||
# STL binary parsing is near-instant (numpy min/max) vs re-parsing the STEP file.
|
||||
# Falls back to cadquery STEP re-parse if STL is not found.
|
||||
# Extract bounding box from the thumbnail GLB generated by the renderer.
|
||||
# GLB bbox via trimesh is fast and avoids re-parsing the STEP file.
|
||||
# Falls back to cadquery STEP re-parse if GLB is not found.
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -247,8 +239,8 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
|
||||
if _step_path and not (_cad2.mesh_attributes or {}).get("dimensions_mm"):
|
||||
_step = Path(_step_path)
|
||||
_stl = _step.parent / f"{_step.stem}_low.stl"
|
||||
bbox_data = _bbox_from_stl(str(_stl)) or _bbox_from_step_cadquery(_step_path)
|
||||
_glb = _step.parent / f"{_step.stem}_thumbnail.glb"
|
||||
bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(_step_path)
|
||||
if bbox_data:
|
||||
_eng2 = create_engine(_sync_url2)
|
||||
with Session(_eng2) as _sess2:
|
||||
@@ -295,6 +287,13 @@ def render_step_thumbnail(self, cad_file_id: str):
|
||||
except Exception:
|
||||
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
|
||||
|
||||
# Auto-generate geometry GLB so the 3D viewer is ready without manual trigger
|
||||
try:
|
||||
generate_gltf_geometry_task.delay(cad_file_id)
|
||||
logger.info("render_step_thumbnail: queued generate_gltf_geometry_task for %s", cad_file_id)
|
||||
except Exception:
|
||||
logger.debug("Could not queue generate_gltf_geometry_task (non-fatal)")
|
||||
|
||||
|
||||
@celery_app.task(name="app.tasks.step_tasks.reextract_cad_metadata", queue="thumbnail_rendering")
|
||||
def reextract_cad_metadata(cad_file_id: str):
|
||||
@@ -321,8 +320,8 @@ def reextract_cad_metadata(cad_file_id: str):
|
||||
|
||||
try:
|
||||
p = Path(step_path)
|
||||
stl_path = p.parent / f"{p.stem}_low.stl"
|
||||
patch = _bbox_from_stl(str(stl_path)) or _bbox_from_step_cadquery(step_path)
|
||||
glb_path = p.parent / f"{p.stem}_thumbnail.glb"
|
||||
patch = _bbox_from_glb(str(glb_path)) or _bbox_from_step_cadquery(step_path)
|
||||
if patch:
|
||||
with Session(eng) as session:
|
||||
cad_file = session.get(CadFile, cad_file_id)
|
||||
@@ -342,72 +341,22 @@ def reextract_cad_metadata(cad_file_id: str):
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_stl_cache", queue="thumbnail_rendering")
|
||||
def generate_stl_cache(self, cad_file_id: str, quality: str):
|
||||
"""Generate and cache STL for a CAD file without triggering a full render."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from app.config import settings as app_settings
|
||||
from app.models.cad_file import CadFile
|
||||
|
||||
logger.info(f"Generating {quality}-quality STL for CAD file: {cad_file_id}")
|
||||
|
||||
sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
eng = create_engine(sync_url)
|
||||
with Session(eng) as session:
|
||||
cad_file = session.get(CadFile, cad_file_id)
|
||||
if not cad_file or not cad_file.stored_path:
|
||||
logger.error(f"CAD file not found or no stored_path: {cad_file_id}")
|
||||
return
|
||||
step_path = cad_file.stored_path
|
||||
eng.dispose()
|
||||
|
||||
try:
|
||||
from app.services.render_blender import convert_step_to_stl, export_per_part_stls
|
||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache, store_stl_cache
|
||||
from pathlib import Path as _Path
|
||||
step = _Path(step_path)
|
||||
stl_out = step.parent / f"{step.stem}_{quality}.stl"
|
||||
parts_dir = step.parent / f"{step.stem}_{quality}_parts"
|
||||
|
||||
if not stl_out.exists() or stl_out.stat().st_size == 0:
|
||||
# Check MinIO cache before running cadquery conversion
|
||||
step_hash = compute_step_hash(step_path)
|
||||
cached_bytes = check_stl_cache(step_hash, quality)
|
||||
if cached_bytes:
|
||||
stl_out.write_bytes(cached_bytes)
|
||||
logger.info(f"STL cache hit for {cad_file_id} ({quality}), skipped conversion")
|
||||
else:
|
||||
convert_step_to_stl(step, stl_out, quality)
|
||||
# Store result in MinIO for future workers
|
||||
if stl_out.exists() and stl_out.stat().st_size > 0:
|
||||
store_stl_cache(step_hash, quality, str(stl_out))
|
||||
if not (parts_dir / "manifest.json").exists():
|
||||
try:
|
||||
export_per_part_stls(step, parts_dir, quality)
|
||||
except Exception as pe:
|
||||
logger.warning(f"Per-part STL export non-fatal: {pe}")
|
||||
logger.info(f"STL cached: {stl_out} ({stl_out.stat().st_size // 1024} KB)")
|
||||
except Exception as exc:
|
||||
logger.error(f"STL generation failed for {cad_file_id} quality={quality}: {exc}")
|
||||
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.generate_gltf_geometry_task", queue="thumbnail_rendering", max_retries=1)
|
||||
def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
"""Export a GLB via Blender with material substitution and OCC sharp-edge data.
|
||||
"""Export a geometry GLB directly from STEP via OCC (no STL intermediary).
|
||||
|
||||
Pipeline:
|
||||
1. Reads sharp_edge_midpoints from cad_file.mesh_attributes (from OCC extraction)
|
||||
2. Resolves material_map via alias lookup (part_name → SCHAEFFLER library material)
|
||||
3. Runs Blender headless with export_gltf.py: STL → GLB with library materials + sharp edges
|
||||
4. Falls back to trimesh (geometry-only, no materials) if Blender is unavailable
|
||||
1. Reads STEP file directly (no STL needed)
|
||||
2. Builds color_map from product.cad_part_materials (hex colors)
|
||||
3. Runs export_step_to_gltf.py (Python/OCP): STEP → GLB with per-part colors
|
||||
4. Stores result as gltf_geometry MediaAsset (replaces any existing one)
|
||||
|
||||
Replaces the existing gltf_geometry MediaAsset for this CadFile on each run.
|
||||
Output is in meters, Y-up (glTF convention).
|
||||
"""
|
||||
import json as _json
|
||||
import os as _os
|
||||
import subprocess as _subprocess
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
from sqlalchemy import create_engine, select as _select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -422,115 +371,68 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
logger.error("generate_gltf_geometry_task: no stored_path for %s", cad_file_id)
|
||||
return
|
||||
step_path_str = cad_file.stored_path
|
||||
mesh_attrs = cad_file.mesh_attributes or {}
|
||||
sharp_edge_midpoints = mesh_attrs.get("sharp_edge_midpoints", [])
|
||||
|
||||
# Load product materials for this CAD file
|
||||
# Build hex color_map from product.cad_part_materials
|
||||
from app.domains.products.models import Product
|
||||
product = session.execute(
|
||||
_select(Product).where(Product.cad_file_id == cad_file.id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
raw_material_map: dict[str, str] = {}
|
||||
color_map: dict[str, str] = {}
|
||||
product_id = str(product.id) if product else 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", "")
|
||||
mat_name = entry.get("material_name") or entry.get("material", "")
|
||||
if part_name and mat_name:
|
||||
raw_material_map[part_name] = mat_name
|
||||
hex_color = entry.get("hex_color") or entry.get("color", "")
|
||||
if part_name and hex_color:
|
||||
color_map[part_name] = hex_color
|
||||
eng.dispose()
|
||||
|
||||
# Resolve aliases: "Steel--Stahl" → "SCHAEFFLER_010101_Steel-Bare"
|
||||
from app.services.material_service import resolve_material_map
|
||||
material_map = resolve_material_map(raw_material_map)
|
||||
|
||||
# Get asset library .blend path from system settings
|
||||
from app.services.template_service import get_material_library_path
|
||||
asset_library_blend = get_material_library_path()
|
||||
|
||||
step = _Path(step_path_str)
|
||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
||||
if not stl_path.exists():
|
||||
log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error")
|
||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
||||
if not step.exists():
|
||||
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
|
||||
raise RuntimeError(f"STEP file not found: {step}")
|
||||
|
||||
output_path = step.parent / f"{step.stem}_geometry.glb"
|
||||
|
||||
log_task_event(
|
||||
self.request.id,
|
||||
f"Starting GLB export: {len(material_map)} materials, "
|
||||
f"{len(sharp_edge_midpoints)} sharp-edge hints, "
|
||||
f"library={'yes' if asset_library_blend else 'no'}",
|
||||
f"Starting OCC GLB export: {len(color_map)} part colors",
|
||||
"info",
|
||||
)
|
||||
|
||||
# --- Blender path ---
|
||||
blender_bin = _os.environ.get("BLENDER_BIN", "blender")
|
||||
# Run export_step_to_gltf.py as a subprocess so OCP imports don't pollute worker state
|
||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
script_path = scripts_dir / "export_gltf.py"
|
||||
blender_ok = False
|
||||
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||
|
||||
if _Path(blender_bin).exists() and script_path.exists():
|
||||
python_bin = _sys.executable
|
||||
cmd = [
|
||||
blender_bin, "--background", "--python", str(script_path), "--",
|
||||
"--stl_path", str(stl_path),
|
||||
python_bin, str(script_path),
|
||||
"--step_path", str(step),
|
||||
"--output_path", str(output_path),
|
||||
"--material_map", _json.dumps(material_map),
|
||||
"--sharp_edges_json", _json.dumps(sharp_edge_midpoints),
|
||||
"--color_map", _json.dumps(color_map),
|
||||
]
|
||||
if asset_library_blend and _Path(asset_library_blend).exists():
|
||||
cmd += ["--asset_library_blend", asset_library_blend]
|
||||
|
||||
try:
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
|
||||
blender_ok = True
|
||||
logger.info("generate_gltf_geometry_task: Blender export succeeded (%s KB)",
|
||||
output_path.stat().st_size // 1024)
|
||||
else:
|
||||
logger.warning(
|
||||
"Blender GLB export failed (exit %d) — falling back to trimesh.\n"
|
||||
"STDOUT: %s\nSTDERR: %s",
|
||||
result.returncode, result.stdout[-1500:], result.stderr[-500:],
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
for line in result.stdout.splitlines():
|
||||
logger.info("[occ-gltf] %s", line)
|
||||
for line in result.stderr.splitlines():
|
||||
logger.warning("[occ-gltf stderr] %s", line)
|
||||
|
||||
if result.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0:
|
||||
raise RuntimeError(
|
||||
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
|
||||
f"STDERR: {result.stderr[-1000:]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Blender GLB export error (%s) — falling back to trimesh", exc)
|
||||
else:
|
||||
logger.warning(
|
||||
"Blender not available at '%s' or script missing at '%s' — using trimesh fallback",
|
||||
blender_bin, script_path,
|
||||
)
|
||||
|
||||
# --- Trimesh fallback (geometry only, no materials) ---
|
||||
if not blender_ok:
|
||||
try:
|
||||
import trimesh
|
||||
import trimesh as _trimesh
|
||||
|
||||
def _process_mesh(m):
|
||||
m.apply_scale(0.001)
|
||||
try:
|
||||
_trimesh.smoothing.filter_laplacian(m, lamb=0.5, iterations=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mesh = trimesh.load(str(stl_path))
|
||||
if hasattr(mesh, 'geometry'):
|
||||
for sub in mesh.geometry.values():
|
||||
_process_mesh(sub)
|
||||
else:
|
||||
_process_mesh(mesh)
|
||||
mesh.export(str(output_path))
|
||||
log_task_event(self.request.id, "Trimesh fallback export completed (no materials)", "done")
|
||||
except Exception as exc:
|
||||
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
||||
logger.error("generate_gltf_geometry_task trimesh fallback failed: %s", exc)
|
||||
logger.error("generate_gltf_geometry_task OCC export failed: %s", exc)
|
||||
raise self.retry(exc=exc, countdown=15)
|
||||
else:
|
||||
log_task_event(self.request.id, f"Blender GLB export completed: {output_path.name}", "done")
|
||||
|
||||
log_task_event(self.request.id, f"OCC GLB export completed: {output_path.name}", "done")
|
||||
|
||||
# --- Store MediaAsset (replace existing gltf_geometry for this cad_file) ---
|
||||
# Use sync SQLAlchemy to avoid asyncio event-loop conflicts in Celery workers.
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import create_engine as _ce, delete as _del
|
||||
from sqlalchemy.orm import Session as _Session
|
||||
@@ -551,6 +453,7 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
_key = _key[len(_prefix):]
|
||||
asset = MediaAsset(
|
||||
cad_file_id=_uuid.UUID(cad_file_id),
|
||||
product_id=_uuid.UUID(product_id) if product_id else None,
|
||||
asset_type=MediaAssetType.gltf_geometry,
|
||||
storage_key=_key,
|
||||
mime_type="model/gltf-binary",
|
||||
@@ -565,6 +468,146 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
||||
return {"glb_path": str(output_path), "asset_id": asset_id}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
name="app.tasks.step_tasks.generate_gltf_production_task",
|
||||
queue="thumbnail_rendering",
|
||||
max_retries=2,
|
||||
)
|
||||
def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None = None) -> dict:
|
||||
"""Generate a production GLB (Blender + PBR materials) from a geometry GLB via export_gltf.py.
|
||||
|
||||
1. Ensures a gltf_geometry MediaAsset exists (runs OCC export inline if not).
|
||||
2. Resolves SCHAEFFLER material map for the CadFile's product.
|
||||
3. Runs Blender headless with export_gltf.py → production GLB.
|
||||
4. Stores result as gltf_production MediaAsset.
|
||||
"""
|
||||
import json as _json
|
||||
import os as _os
|
||||
import subprocess as _subprocess
|
||||
import uuid as _uuid
|
||||
from pathlib import Path as _Path
|
||||
|
||||
from sqlalchemy import create_engine as _ce, delete as _del, select as _sel
|
||||
from sqlalchemy.orm import Session as _Session
|
||||
|
||||
from app.config import settings as app_settings
|
||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||
from app.services.render_blender import find_blender, is_blender_available
|
||||
|
||||
log_task_event(self.request.id, f"generate_gltf_production_task started for cad {cad_file_id}", "info")
|
||||
|
||||
_sync_url = app_settings.database_url.replace("+asyncpg", "")
|
||||
_eng = _ce(_sync_url)
|
||||
|
||||
# --- 1. Resolve geometry GLB path from existing gltf_geometry MediaAsset ---
|
||||
with _Session(_eng) as _sess:
|
||||
_row = _sess.execute(
|
||||
_sel(MediaAsset).where(
|
||||
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
|
||||
MediaAsset.asset_type == MediaAssetType.gltf_geometry,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
geom_glb_key = _row.storage_key if _row else None
|
||||
|
||||
if not geom_glb_key:
|
||||
# Trigger geometry generation first and retry this task
|
||||
log_task_event(self.request.id, "No gltf_geometry asset found — queuing geometry task first", "info")
|
||||
generate_gltf_geometry_task.delay(cad_file_id, product_id)
|
||||
raise self.retry(exc=RuntimeError("gltf_geometry not yet available"), countdown=30, max_retries=2)
|
||||
|
||||
geom_glb_path = _Path(app_settings.upload_dir) / geom_glb_key
|
||||
if not geom_glb_path.exists():
|
||||
raise RuntimeError(f"Geometry GLB not found on disk: {geom_glb_path}")
|
||||
|
||||
# --- 2. Resolve material map (SCHAEFFLER library names) ---
|
||||
from app.services.material_service import resolve_material_map
|
||||
|
||||
with _Session(_eng) as _sess:
|
||||
from app.models.cad_file import CadFile as _CF
|
||||
_cad = _sess.execute(_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))).scalar_one_or_none()
|
||||
raw_mat_map: dict = {}
|
||||
if _cad and _cad.cad_part_materials:
|
||||
raw_mat_map = _cad.cad_part_materials
|
||||
|
||||
mat_map = resolve_material_map(raw_mat_map)
|
||||
|
||||
# --- 3. Resolve asset library .blend path from system settings ---
|
||||
from app.models.system_setting import SystemSetting
|
||||
with _Session(_eng) as _sess:
|
||||
_setting = _sess.execute(
|
||||
_sel(SystemSetting).where(SystemSetting.key == "asset_library_blend")
|
||||
).scalar_one_or_none()
|
||||
asset_library_blend = _setting.value if _setting and _setting.value else ""
|
||||
_eng.dispose()
|
||||
|
||||
# Output path next to geometry GLB
|
||||
output_path = geom_glb_path.parent / (geom_glb_path.stem.replace("_geometry", "") + "_production.glb")
|
||||
|
||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||
export_script = scripts_dir / "export_gltf.py"
|
||||
|
||||
if not is_blender_available():
|
||||
raise RuntimeError("Blender is not available — cannot generate production GLB")
|
||||
if not export_script.exists():
|
||||
raise RuntimeError(f"export_gltf.py not found at {export_script}")
|
||||
|
||||
blender_bin = find_blender()
|
||||
cmd = [
|
||||
blender_bin, "--background",
|
||||
"--python", str(export_script),
|
||||
"--",
|
||||
"--glb_path", str(geom_glb_path),
|
||||
"--output_path", str(output_path),
|
||||
"--material_map", _json.dumps(mat_map),
|
||||
]
|
||||
if asset_library_blend:
|
||||
cmd += ["--asset_library_blend", asset_library_blend]
|
||||
|
||||
log_task_event(self.request.id, f"Running Blender export_gltf.py for {geom_glb_path.name}", "info")
|
||||
try:
|
||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error")
|
||||
logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc)
|
||||
raise self.retry(exc=exc, countdown=30)
|
||||
|
||||
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
|
||||
|
||||
# --- 4. Store MediaAsset (replace existing gltf_production for this cad_file) ---
|
||||
_eng2 = _ce(_sync_url)
|
||||
with _Session(_eng2) as _sess:
|
||||
_sess.execute(
|
||||
_del(MediaAsset).where(
|
||||
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
|
||||
MediaAsset.asset_type == MediaAssetType.gltf_production,
|
||||
)
|
||||
)
|
||||
_key = str(output_path)
|
||||
_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
|
||||
if _key.startswith(_prefix):
|
||||
_key = _key[len(_prefix):]
|
||||
asset = MediaAsset(
|
||||
cad_file_id=_uuid.UUID(cad_file_id),
|
||||
product_id=_uuid.UUID(product_id) if product_id else None,
|
||||
asset_type=MediaAssetType.gltf_production,
|
||||
storage_key=_key,
|
||||
mime_type="model/gltf-binary",
|
||||
file_size_bytes=output_path.stat().st_size if output_path.exists() else None,
|
||||
)
|
||||
_sess.add(asset)
|
||||
_sess.commit()
|
||||
asset_id = str(asset.id)
|
||||
_eng2.dispose()
|
||||
|
||||
logger.info("generate_gltf_production_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
|
||||
return {"glb_path": str(output_path), "asset_id": asset_id}
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="app.tasks.step_tasks.regenerate_thumbnail", queue="thumbnail_rendering")
|
||||
def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
|
||||
"""Regenerate thumbnail with per-part colours."""
|
||||
|
||||
+6
-39
@@ -67,30 +67,6 @@ export async function getCadObjects(cadFileId: string): Promise<CadObjects> {
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the cached STL for a CAD file as a file-save dialog.
|
||||
* quality: 'low' | 'high'
|
||||
* The backend returns a human-readable filename, but we derive it client-side too.
|
||||
*/
|
||||
export async function downloadStl(cadFileId: string, quality: 'low' | 'high', suggestedName?: string): Promise<void> {
|
||||
const res = await api.get<Blob>(`/cad/${cadFileId}/stl/${quality}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(res.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = suggestedName ? `${suggestedName}_${quality}.stl` : `model_${quality}.stl`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function generateStl(cadFileId: string, quality: 'low' | 'high'): Promise<{ task_id: string }> {
|
||||
const res = await api.post<{ task_id: string }>(`/cad/${cadFileId}/generate-stl/${quality}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the backend to re-queue STEP processing for a CAD file (admin only).
|
||||
* Returns the Celery task_id (or null if the worker is not available).
|
||||
@@ -110,23 +86,14 @@ export interface GenerateGltfResponse {
|
||||
cad_file_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue GLB geometry export from existing STL cache (trimesh, no Blender).
|
||||
* The STL low-quality cache must already exist.
|
||||
*/
|
||||
/** Queue geometry GLB export directly from STEP via OCC (no Blender, no STL). */
|
||||
export async function generateGltfGeometry(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-geometry`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export const exportGltfColored = (id: string): Promise<void> =>
|
||||
api.get(`/cad/${id}/export-gltf-colored`, { responseType: 'blob' }).then(r => {
|
||||
const url = URL.createObjectURL(r.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${id}_colored.glb`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
/** Queue production GLB export (Blender + PBR materials) from geometry GLB. */
|
||||
export async function generateGltfProduction(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-production`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface Product {
|
||||
thumbnail_url: string | null
|
||||
render_image_url: string | null
|
||||
processing_status: string | null
|
||||
stl_cached: string[]
|
||||
cad_parsed_objects: string[] | null
|
||||
cad_mesh_attributes?: {
|
||||
dimensions_mm?: { x: number; y: number; z: number }
|
||||
|
||||
@@ -31,7 +31,7 @@ function GlbModel({ url, wireframe }: { url: string; wireframe: boolean }) {
|
||||
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||||
let geo = obj.geometry.clone()
|
||||
if (!geo.index) {
|
||||
// Non-indexed geometry (STL→GLB via trimesh): each triangle has unique vertices,
|
||||
// Non-indexed geometry: each triangle has unique vertices,
|
||||
// so computeVertexNormals() would give per-face normals (flat shading).
|
||||
// mergeVertices() creates an indexed geometry with shared vertices first,
|
||||
// so the subsequent normal computation averages across adjacent faces → smooth.
|
||||
@@ -208,7 +208,7 @@ export default function InlineCadViewer({
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => generateMut.mutate()}
|
||||
disabled={generateMut.isPending || generating}
|
||||
title="Export STL to GLB and load 3D viewer"
|
||||
title="Export geometry GLB from STEP via OCC and load 3D viewer"
|
||||
>
|
||||
<RefreshCw size={12} className={generating ? 'animate-spin' : ''} />
|
||||
{generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'}
|
||||
|
||||
@@ -73,7 +73,6 @@ export default function AdminPage() {
|
||||
blender_eevee_samples: number
|
||||
threejs_render_size: number
|
||||
thumbnail_format: string
|
||||
stl_quality: string
|
||||
blender_smooth_angle: number
|
||||
cycles_device: string
|
||||
blender_max_concurrent_renders: number
|
||||
@@ -159,14 +158,6 @@ export default function AdminPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
|
||||
})
|
||||
|
||||
const generateMissingStlsMut = useMutation({
|
||||
mutationFn: () => api.post('/admin/settings/generate-missing-stls'),
|
||||
onSuccess: (res) => {
|
||||
toast.success(res.data.message || 'STL generation queued')
|
||||
},
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
|
||||
})
|
||||
|
||||
const reextractMetadataMut = useMutation({
|
||||
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
|
||||
onSuccess: (res) => {
|
||||
@@ -397,29 +388,6 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STL quality */}
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">STL quality</span>
|
||||
{(['low', 'high'] as const).map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setBlenderDraft((d) => ({ ...d, stl_quality: q }))}
|
||||
className={`px-4 py-1.5 rounded-full border text-sm font-medium transition-colors ${
|
||||
blender.stl_quality === q
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-surface text-content-secondary border-border-default hover:border-blue-400 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{q === 'low' ? 'Low (fast)' : 'High (detailed)'}
|
||||
</button>
|
||||
))}
|
||||
<p className="text-xs text-content-muted">
|
||||
{blender.stl_quality === 'high'
|
||||
? 'Fine mesh (tol=0.01) — slower STEP→STL, sharper edges.'
|
||||
: 'Coarse mesh (tol=0.3) — faster, good for previews.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Smooth by angle */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm font-medium text-content-secondary w-28 shrink-0">Smooth angle</span>
|
||||
@@ -704,18 +672,6 @@ export default function AdminPage() {
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Registers existing renders & CAD thumbnails in the Media Browser.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => generateMissingStlsMut.mutate()}
|
||||
disabled={generateMissingStlsMut.isPending}
|
||||
className="btn-secondary text-sm w-full justify-start"
|
||||
title="Queue STL conversion for every low/high quality that is not yet cached on disk"
|
||||
>
|
||||
<RefreshCw size={14} className={generateMissingStlsMut.isPending ? 'animate-spin' : ''} />
|
||||
{generateMissingStlsMut.isPending ? 'Queueing…' : 'Generate Missing STLs'}
|
||||
</button>
|
||||
<p className="text-xs text-content-muted">Generates low + high STL files for completed STEP files missing them.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => reextractMetadataMut.mutate()}
|
||||
|
||||
@@ -96,8 +96,8 @@ export default function CadPreviewPage() {
|
||||
<Box size={48} className="text-gray-600" />
|
||||
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
|
||||
<p className="text-gray-400 text-sm max-w-sm">
|
||||
Generate a GLB file from the STEP cache to enable the 3D viewer.
|
||||
The STL cache must exist (process the STEP file first).
|
||||
Generate a geometry GLB from the STEP file to enable the 3D viewer.
|
||||
Process the STEP file first to make it available.
|
||||
</p>
|
||||
{generating ? (
|
||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||
@@ -116,7 +116,7 @@ export default function CadPreviewPage() {
|
||||
)}
|
||||
{generateMutation.isError && (
|
||||
<p className="text-red-400 text-sm">
|
||||
Failed to start generation. Check that the STL cache exists.
|
||||
Failed to start generation. Make sure the STEP file has been processed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { listMaterials } from '../api/materials'
|
||||
import MaterialInput from '../components/shared/MaterialInput'
|
||||
import MaterialWizard from '../components/MaterialWizard'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { downloadStl, generateStl, generateGltfGeometry, exportGltfColored } from '../api/cad'
|
||||
import { generateGltfGeometry, generateGltfProduction } from '../api/cad'
|
||||
import InlineCadViewer from '../components/cad/InlineCadViewer'
|
||||
|
||||
function CadStatusBadge({ status }: { status: string | null }) {
|
||||
@@ -290,11 +290,6 @@ export default function ProductDetailPage() {
|
||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
|
||||
})
|
||||
|
||||
const exportGltfColoredMut = useMutation({
|
||||
mutationFn: () => exportGltfColored(product?.cad_file_id!),
|
||||
onError: () => toast.error('GLB export failed'),
|
||||
})
|
||||
|
||||
const [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
|
||||
|
||||
const POSITION_PRESETS = [
|
||||
@@ -526,160 +521,87 @@ export default function ProductDetailPage() {
|
||||
|
||||
{product.cad_file_id ? (
|
||||
<div className="space-y-3">
|
||||
{/* Inline 3D Viewer */}
|
||||
{/* Two-column: viewer left, actions right */}
|
||||
<div className="flex gap-4 items-start">
|
||||
{/* Left: Inline 3D Viewer */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<InlineCadViewer
|
||||
cadFileId={product.cad_file_id}
|
||||
thumbnailUrl={product.render_image_url || product.thumbnail_url}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Action buttons */}
|
||||
<div className="flex flex-col gap-2 shrink-0 w-44">
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
|
||||
title="Open interactive 3D viewer in full screen"
|
||||
>
|
||||
<Cuboid size={12} />
|
||||
View Full Screen
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isPrivileged && (
|
||||
<>
|
||||
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
|
||||
<div {...getRootProps()} className="cursor-pointer">
|
||||
<input {...getInputProps()} />
|
||||
<button className="btn-secondary text-xs" disabled={cadUploadMut.isPending}>
|
||||
<button className="btn-secondary text-xs w-full justify-start" disabled={cadUploadMut.isPending}>
|
||||
<Upload size={12} />
|
||||
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() => regenerateMut.mutate()}
|
||||
disabled={regenerateMut.isPending}
|
||||
title="Re-render the thumbnail using the current part materials and the active thumbnail renderer — keeps the existing STEP parse data"
|
||||
title="Re-render thumbnail with current materials"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
{regenerateMut.isPending ? 'Queuing…' : 'Regenerate thumbnail'}
|
||||
{regenerateMut.isPending ? 'Queuing…' : 'Regen thumbnail'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() => reprocessMut.mutate()}
|
||||
disabled={reprocessMut.isPending}
|
||||
title="Re-run full STEP processing: re-parse part names, regenerate thumbnail and glTF. Use this after re-uploading a STEP file."
|
||||
title="Re-parse STEP + regenerate thumbnail and glTF"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
|
||||
title="Open interactive 3D viewer in full screen"
|
||||
>
|
||||
<Cuboid size={12} />
|
||||
View Full Screen
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() =>
|
||||
generateGltfGeometry(product.cad_file_id!)
|
||||
.then(() => toast.info('GLB geometry export queued'))
|
||||
.catch(() => toast.error('Failed to queue GLB export'))
|
||||
}
|
||||
title="Export geometry-only GLB from cached STL (trimesh, no Blender). Requires STL cache."
|
||||
>
|
||||
<Download size={12} />
|
||||
Generate GLB
|
||||
</button>
|
||||
{product?.processing_status === 'completed' && (
|
||||
<button
|
||||
onClick={() => exportGltfColoredMut.mutate()}
|
||||
disabled={exportGltfColoredMut.isPending}
|
||||
className="btn-secondary flex items-center gap-2 disabled:opacity-40 text-xs"
|
||||
title="Download GLB with PBR colors from material assignments"
|
||||
>
|
||||
<Download size={12} />
|
||||
{exportGltfColoredMut.isPending ? 'Exporting…' : 'GLB + Colors'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 pl-1 border-l border-border-light">
|
||||
<p className="text-xs text-content-muted font-medium">STL:</p>
|
||||
{(['low', 'high'] as const).map((q) =>
|
||||
product.stl_cached.includes(q) ? (
|
||||
<button
|
||||
key={q}
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => downloadStl(product.cad_file_id!, q, product.name_cad_modell || product.name || undefined)}
|
||||
title={q === 'low' ? 'Coarse mesh, tolerance 0.3 mm' : 'Fine mesh, tolerance 0.01 mm'}
|
||||
>
|
||||
<Download size={12} /> {q === 'low' ? 'Low' : 'High'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={q}
|
||||
className="btn-secondary text-xs opacity-60"
|
||||
onClick={() => generateStl(product.cad_file_id!, q).then(() => toast.info(`STL generation queued (${q} quality)`)).catch(() => toast.error('Failed to queue STL generation'))}
|
||||
title={`${q === 'low' ? 'Low' : 'High'}-quality STL not cached — click to generate`}
|
||||
>
|
||||
<RefreshCw size={12} /> Gen {q === 'low' ? 'Low' : 'High'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isPrivileged && product.cad_file_id && (
|
||||
<button
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => navigate(`/cad/${product.cad_file_id}`)}
|
||||
title="Open interactive 3D viewer in full screen"
|
||||
>
|
||||
<Cuboid size={12} />
|
||||
View Full Screen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mesh attributes */}
|
||||
{(() => {
|
||||
// Prefer cad_mesh_attributes (reliably populated by API) over cad_file.mesh_attributes
|
||||
const mesh_attrs: Record<string, unknown> = (product.cad_mesh_attributes ?? product.cad_file?.mesh_attributes) as Record<string, unknown> ?? {}
|
||||
if (Object.keys(mesh_attrs).length === 0) return null
|
||||
const dims = mesh_attrs.dimensions_mm as { x: number; y: number; z: number } | undefined
|
||||
const bbox = mesh_attrs.bbox as { x?: number; y?: number; z?: number } | undefined
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-md border border-border-default bg-surface-alt">
|
||||
<p className="text-xs font-semibold text-content-muted mb-2 flex items-center gap-1">
|
||||
<Ruler size={12} />
|
||||
Geometry
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{dims != null && (
|
||||
<>
|
||||
<span className="text-content-muted">Dimensions</span>
|
||||
<span>{dims.x.toFixed(1)} × {dims.y.toFixed(1)} × {dims.z.toFixed(1)} mm</span>
|
||||
</>
|
||||
)}
|
||||
{dims == null && bbox != null && (
|
||||
<>
|
||||
<span className="text-content-muted">BBox</span>
|
||||
<span>
|
||||
{bbox.x?.toFixed(1)} × {bbox.y?.toFixed(1)} × {bbox.z?.toFixed(1)} mm
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{(mesh_attrs.volume_mm3 as number | undefined) != null && (
|
||||
<>
|
||||
<span className="text-content-muted">Volume</span>
|
||||
<span>{((mesh_attrs.volume_mm3 as number) / 1000).toFixed(2)} cm³</span>
|
||||
</>
|
||||
)}
|
||||
{(mesh_attrs.surface_area_mm2 as number | undefined) != null && (
|
||||
<>
|
||||
<span className="text-content-muted">Surface</span>
|
||||
<span>{((mesh_attrs.surface_area_mm2 as number) / 100).toFixed(1)} cm²</span>
|
||||
</>
|
||||
)}
|
||||
{mesh_attrs.suggested_smooth_angle !== undefined && (
|
||||
<>
|
||||
<span className="text-content-muted">Sharp angle</span>
|
||||
<span>{mesh_attrs.suggested_smooth_angle as number}°</span>
|
||||
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() =>
|
||||
generateGltfGeometry(product.cad_file_id!)
|
||||
.then(() => toast.info('Geometry GLB export queued'))
|
||||
.catch(() => toast.error('Failed to queue GLB export'))
|
||||
}
|
||||
title="Export geometry GLB directly from STEP via OCC (no Blender)"
|
||||
>
|
||||
<Download size={12} />
|
||||
Generate Geometry GLB
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary text-xs w-full justify-start"
|
||||
onClick={() =>
|
||||
generateGltfProduction(product.cad_file_id!)
|
||||
.then(() => toast.info('Production GLB export queued'))
|
||||
.catch(() => toast.error('Failed to queue production GLB export'))
|
||||
}
|
||||
title="Export production GLB with PBR materials via Blender"
|
||||
>
|
||||
<Download size={12} />
|
||||
Generate Production GLB
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Material assignments */}
|
||||
{isPrivileged && (
|
||||
|
||||
@@ -57,12 +57,12 @@ else:
|
||||
|
||||
if len(argv) < 4:
|
||||
print("Usage: blender --background --python blender_render.py -- "
|
||||
"<stl_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
|
||||
"<glb_path> <output_path> <width> <height> [engine] [samples] [smooth_angle] [cycles_device] [transparent_bg]")
|
||||
sys.exit(1)
|
||||
|
||||
import json as _json
|
||||
|
||||
stl_path = argv[0]
|
||||
glb_path = argv[0]
|
||||
output_path = argv[1]
|
||||
width = int(argv[2])
|
||||
height = int(argv[3])
|
||||
@@ -173,23 +173,7 @@ def _assign_palette_material(part_obj, index):
|
||||
import re as _re
|
||||
|
||||
|
||||
def _scale_mm_to_m(parts):
|
||||
"""Scale imported STL objects from mm to Blender metres (×0.001).
|
||||
|
||||
STEP/STL coordinates are in mm; Blender's default unit is metres.
|
||||
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
|
||||
relative to any template environment designed in metric units.
|
||||
"""
|
||||
if not parts:
|
||||
return
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for p in parts:
|
||||
p.scale = (0.001, 0.001, 0.001)
|
||||
p.location *= 0.001
|
||||
p.select_set(True)
|
||||
bpy.context.view_layer.objects.active = parts[0]
|
||||
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
|
||||
print(f"[blender_render] scaled {len(parts)} parts mm→m (×0.001)")
|
||||
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
|
||||
|
||||
|
||||
def _apply_rotation(parts, rx, ry, rz):
|
||||
@@ -276,74 +260,26 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def _import_stl(stl_file):
|
||||
"""Import STL into Blender, using per-part STLs if available.
|
||||
def _import_glb(glb_file):
|
||||
"""Import OCC-generated GLB into Blender.
|
||||
|
||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
||||
- Fallback: imports combined STL and splits by loose geometry.
|
||||
OCC exports one mesh object per STEP part, already in metres.
|
||||
Blender's native GLTF importer preserves part names.
|
||||
|
||||
Returns list of Blender mesh objects, centred at origin.
|
||||
Returns list of Blender mesh objects, centred at world origin.
|
||||
"""
|
||||
stl_dir = os.path.dirname(stl_file)
|
||||
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
|
||||
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
|
||||
manifest_path = os.path.join(parts_dir, "manifest.json")
|
||||
|
||||
parts = []
|
||||
|
||||
if os.path.isfile(manifest_path):
|
||||
# ── Per-part mode ────────────────────────────────────────────────
|
||||
try:
|
||||
with open(manifest_path, "r") as f:
|
||||
manifest = _json.loads(f.read())
|
||||
part_entries = manifest.get("parts", [])
|
||||
except Exception as e:
|
||||
print(f"[blender_render] WARNING: failed to read manifest: {e}")
|
||||
part_entries = []
|
||||
|
||||
if part_entries:
|
||||
for entry in part_entries:
|
||||
part_file = os.path.join(parts_dir, entry["file"])
|
||||
part_name = entry["name"]
|
||||
if not os.path.isfile(part_file):
|
||||
print(f"[blender_render] WARNING: part STL missing: {part_file}")
|
||||
continue
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
bpy.ops.wm.stl_import(filepath=part_file)
|
||||
imported = bpy.context.selected_objects
|
||||
if imported:
|
||||
obj = imported[0]
|
||||
obj.name = part_name
|
||||
if obj.data:
|
||||
obj.data.name = part_name
|
||||
parts.append(obj)
|
||||
bpy.ops.import_scene.gltf(filepath=glb_file)
|
||||
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||
|
||||
if parts:
|
||||
print(f"[blender_render] imported {len(parts)} named parts from per-part STLs")
|
||||
|
||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
||||
if not parts:
|
||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
||||
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
|
||||
if obj is None:
|
||||
print(f"ERROR: No objects imported from {stl_file}")
|
||||
print(f"ERROR: No mesh objects imported from {glb_file}")
|
||||
sys.exit(1)
|
||||
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
print(f"[blender_render] imported {len(parts)} part(s) from GLB: "
|
||||
f"{[p.name for p in parts[:5]]}")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
parts = list(bpy.context.selected_objects)
|
||||
print(f"[blender_render] fallback: separated into {len(parts)} part(s)")
|
||||
return parts
|
||||
|
||||
# ── Centre per-part imports at origin (combined bbox) ────────────────
|
||||
# Centre combined bbox at world origin
|
||||
all_corners = []
|
||||
for p in parts:
|
||||
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
||||
@@ -453,10 +389,8 @@ if use_template:
|
||||
# Find or create target collection
|
||||
target_col = _ensure_collection(target_collection)
|
||||
|
||||
# Import and split STL
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
# Import OCC GLB (already in metres, one object per STEP part)
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation (before camera/bbox calculations)
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
|
||||
@@ -538,9 +472,8 @@ else:
|
||||
# ── MODE A: Factory settings (original behavior) ─────────────────────────
|
||||
needs_auto_camera = True
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
# Import OCC GLB (already in metres, one object per STEP part)
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation (before camera/bbox calculations)
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Blender headless script: export a STEP-derived scene as a production .blend.
|
||||
"""Blender headless script: export a geometry GLB as a production .blend.
|
||||
|
||||
Usage:
|
||||
blender --background --python export_blend.py -- \\
|
||||
--stl_path /path/to/file.stl \\
|
||||
--glb_path /path/to/geometry.glb \\
|
||||
--output_path /path/to/output.blend \\
|
||||
[--asset_library_blend /path/to/library.blend] \\
|
||||
[--material_map '{"SrcMat": "LibMat"}']
|
||||
|
||||
The script:
|
||||
1. Imports the STL file (with mm→m scale).
|
||||
1. Imports the geometry GLB (already in metres, Y-up).
|
||||
2. Optionally applies asset library materials from a .blend.
|
||||
3. Packs all external data.
|
||||
4. Saves a copy as the output .blend.
|
||||
@@ -28,7 +28,8 @@ def parse_args() -> argparse.Namespace:
|
||||
sys.exit(1)
|
||||
rest = argv[argv.index("--") + 1:]
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--stl_path", required=True)
|
||||
parser.add_argument("--glb_path", required=True,
|
||||
help="Geometry GLB from export_step_to_gltf.py (already in metres)")
|
||||
parser.add_argument("--output_path", required=True)
|
||||
parser.add_argument("--asset_library_blend", default=None)
|
||||
parser.add_argument("--material_map", default="{}")
|
||||
@@ -44,14 +45,8 @@ def main() -> None:
|
||||
# Clean scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import STL
|
||||
bpy.ops.import_mesh.stl(filepath=args.stl_path)
|
||||
|
||||
# Scale mm → m
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.scale = (0.001, 0.001, 0.001)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
# Import geometry GLB (metres, Y-up — no rescaling needed)
|
||||
bpy.ops.import_scene.gltf(filepath=args.glb_path)
|
||||
|
||||
# Apply asset library materials if provided
|
||||
if args.asset_library_blend and material_map:
|
||||
|
||||
@@ -27,78 +27,30 @@ def parse_args() -> argparse.Namespace:
|
||||
sys.exit(1)
|
||||
rest = argv[argv.index("--") + 1:]
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--stl_path", required=True)
|
||||
parser.add_argument("--glb_path", required=True,
|
||||
help="Geometry GLB from export_step_to_gltf.py (already in metres)")
|
||||
parser.add_argument("--output_path", required=True)
|
||||
parser.add_argument("--asset_library_blend", default=None)
|
||||
parser.add_argument("--material_map", default="{}")
|
||||
parser.add_argument("--sharp_edges_json", default="[]",
|
||||
help="JSON array of [x, y, z] midpoints (mm) to mark as sharp edges")
|
||||
return parser.parse_args(rest)
|
||||
|
||||
|
||||
def mark_sharp_edges_by_proximity(midpoints_mm: list, threshold_mm: float = 1.0) -> None:
|
||||
"""Mark Blender mesh edges as sharp based on proximity to OCC-derived midpoints.
|
||||
|
||||
midpoints_mm: list of [x, y, z] in mm (from OCC coordinate space).
|
||||
After STL import + scale-apply (mm→m), Blender vertices are in meters, so we
|
||||
convert the edge midpoint back to mm before comparing.
|
||||
threshold_mm: snap distance in mm (default 1.0 mm).
|
||||
"""
|
||||
if not midpoints_mm:
|
||||
return
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
import math
|
||||
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type != "MESH":
|
||||
continue
|
||||
mesh = obj.data
|
||||
# Blender 4.1+ removed use_auto_smooth — use shade_smooth_by_angle instead
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
try:
|
||||
bpy.ops.object.shade_smooth_by_angle(angle=math.radians(30))
|
||||
except Exception:
|
||||
pass # fallback: stay flat-shaded
|
||||
mw = obj.matrix_world
|
||||
for edge in mesh.edges:
|
||||
v1 = mw @ mesh.vertices[edge.vertices[0]].co
|
||||
v2 = mw @ mesh.vertices[edge.vertices[1]].co
|
||||
# Convert Blender meters → mm for comparison
|
||||
mid_mm = [
|
||||
(v1.x + v2.x) / 2 * 1000,
|
||||
(v1.y + v2.y) / 2 * 1000,
|
||||
(v1.z + v2.z) / 2 * 1000,
|
||||
]
|
||||
for hint in midpoints_mm:
|
||||
dist_sq = sum((a - b) ** 2 for a, b in zip(mid_mm, hint))
|
||||
if dist_sq < threshold_mm ** 2:
|
||||
edge.use_edge_sharp = True
|
||||
break
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
material_map: dict = json.loads(args.material_map)
|
||||
sharp_edge_midpoints: list = json.loads(args.sharp_edges_json)
|
||||
|
||||
import bpy # type: ignore[import]
|
||||
import math as _math
|
||||
|
||||
# Clean scene
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
# Import STL (bpy.ops.wm.stl_import is the Blender 4.0+ API)
|
||||
bpy.ops.wm.stl_import(filepath=args.stl_path)
|
||||
|
||||
# Scale mm → m
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.scale = (0.001, 0.001, 0.001)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
|
||||
bpy.ops.import_scene.gltf(filepath=args.glb_path)
|
||||
print(f"Imported geometry GLB: {args.glb_path} "
|
||||
f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)")
|
||||
|
||||
# Apply smooth shading with 30° angle threshold (Blender 4.1+ API)
|
||||
import math as _math
|
||||
for obj in bpy.data.objects:
|
||||
if obj.type == "MESH":
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
@@ -108,37 +60,30 @@ def main() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Mark sharp edges for better UV seams
|
||||
if sharp_edge_midpoints:
|
||||
mark_sharp_edges_by_proximity(sharp_edge_midpoints)
|
||||
print(f"Marked sharp edges from {len(sharp_edge_midpoints)} hint points")
|
||||
|
||||
# Apply asset library materials if provided.
|
||||
# link=False (append) is required for GLB export: the GLTF exporter can only
|
||||
# traverse local (appended) Principled BSDF node trees to extract PBR values.
|
||||
# Linked materials are external references whose node data is not accessible.
|
||||
# link=False (append) is required: the GLTF exporter can only traverse
|
||||
# local (appended) Principled BSDF node trees to extract PBR values.
|
||||
if args.asset_library_blend and material_map:
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from asset_library import apply_asset_library_materials
|
||||
apply_asset_library_materials(args.asset_library_blend, material_map, link=False)
|
||||
|
||||
# Export GLB with full PBR material data
|
||||
# Note: export_colors was removed in Blender 4.x — do not pass it.
|
||||
# Export production GLB with full PBR material data
|
||||
try:
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=args.output_path,
|
||||
export_format="GLB",
|
||||
export_apply=True,
|
||||
use_selection=False,
|
||||
export_materials="EXPORT", # export all materials (Principled BSDF → glTF PBR)
|
||||
export_image_format="AUTO", # embed textures (base color, normal, roughness maps)
|
||||
export_materials="EXPORT",
|
||||
export_image_format="AUTO",
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"GLB export failed: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"GLB exported to {args.output_path}")
|
||||
print(f"Production GLB exported to {args.output_path}")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""OCC-native STEP → GLB export script.
|
||||
|
||||
Reads a STEP file via OCP/XCAF (preserving part names and embedded colors),
|
||||
tessellates with BRepMesh, optionally applies per-part hex colors, and writes
|
||||
a binary GLB in meters (Y-up, glTF convention).
|
||||
|
||||
No Blender required. Uses the same OCP bindings that cadquery ships with.
|
||||
|
||||
Usage:
|
||||
python3 export_step_to_gltf.py \
|
||||
--step_path /path/to/file.stp \
|
||||
--output_path /path/to/output.glb \
|
||||
[--linear_deflection 0.1] \
|
||||
[--angular_deflection 0.5] \
|
||||
[--color_map '{"RingInner": "#4C9BE8", "RingOuter": "#E85B4C"}']
|
||||
|
||||
Exit 0 on success, exit 1 on failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
PALETTE_HEX = [
|
||||
"#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8",
|
||||
"#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8",
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--step_path", required=True)
|
||||
parser.add_argument("--output_path", required=True)
|
||||
parser.add_argument(
|
||||
"--linear_deflection", type=float, default=0.1,
|
||||
help="OCC linear deflection for tessellation (mm). Smaller = finer mesh. Default 0.1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--angular_deflection", type=float, default=0.5,
|
||||
help="OCC angular deflection (radians). Default 0.5",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--color_map", default="{}",
|
||||
help='JSON dict mapping part name → hex color, e.g. \'{"Ring": "#4C9BE8"}\'',
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _hex_to_occ_color(hex_color: str):
|
||||
"""Convert '#RRGGBB' → Quantity_Color (linear float)."""
|
||||
from OCP.Quantity import Quantity_Color, Quantity_TOC_RGB
|
||||
h = hex_color.lstrip("#")
|
||||
if len(h) < 6:
|
||||
return Quantity_Color(0.7, 0.7, 0.7, Quantity_TOC_RGB)
|
||||
r = int(h[0:2], 16) / 255.0
|
||||
g = int(h[2:4], 16) / 255.0
|
||||
b = int(h[4:6], 16) / 255.0
|
||||
return Quantity_Color(r, g, b, Quantity_TOC_RGB)
|
||||
|
||||
|
||||
def _apply_color_map(shape_tool, color_tool, free_labels, color_map: dict) -> None:
|
||||
"""Apply hex colors from color_map to matching shapes by name (case-insensitive substring)."""
|
||||
from OCP.TDF import TDF_LabelSequence
|
||||
from OCP.TDataStd import TDataStd_Name
|
||||
from OCP.XCAFDoc import XCAFDoc_ShapeTool
|
||||
|
||||
# XCAFDoc_ColorType: XCAFDoc_ColorGen=0, XCAFDoc_ColorSurf=1, XCAFDoc_ColorCurv=2
|
||||
try:
|
||||
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
|
||||
except ImportError:
|
||||
COLOR_SURF = 1 # integer fallback
|
||||
|
||||
def _visit(label) -> None:
|
||||
name_attr = TDataStd_Name()
|
||||
name = ""
|
||||
if label.FindAttribute(TDataStd_Name.GetID_s(), name_attr):
|
||||
name = name_attr.Get().ToExtString()
|
||||
|
||||
if name:
|
||||
for part_name, hex_color in color_map.items():
|
||||
if part_name.lower() in name.lower() or name.lower() in part_name.lower():
|
||||
color_tool.SetColor(label, _hex_to_occ_color(hex_color), COLOR_SURF)
|
||||
break
|
||||
|
||||
components = TDF_LabelSequence()
|
||||
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||
for i in range(1, components.Length() + 1):
|
||||
_visit(components.Value(i))
|
||||
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
_visit(free_labels.Value(i))
|
||||
|
||||
|
||||
def _apply_palette_colors(shape_tool, color_tool, free_labels) -> None:
|
||||
"""Assign palette colors to leaf shapes when no color_map is provided."""
|
||||
from OCP.TDF import TDF_LabelSequence
|
||||
from OCP.XCAFDoc import XCAFDoc_ShapeTool
|
||||
|
||||
try:
|
||||
from OCP.XCAFDoc import XCAFDoc_ColorSurf as COLOR_SURF
|
||||
except ImportError:
|
||||
COLOR_SURF = 1
|
||||
|
||||
leaves: list = []
|
||||
|
||||
def _collect(label) -> None:
|
||||
components = TDF_LabelSequence()
|
||||
XCAFDoc_ShapeTool.GetComponents_s(label, components)
|
||||
if components.Length() == 0:
|
||||
leaves.append(label)
|
||||
else:
|
||||
for i in range(1, components.Length() + 1):
|
||||
_collect(components.Value(i))
|
||||
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
_collect(free_labels.Value(i))
|
||||
|
||||
for idx, label in enumerate(leaves):
|
||||
occ_color = _hex_to_occ_color(PALETTE_HEX[idx % len(PALETTE_HEX)])
|
||||
color_tool.SetColor(label, occ_color, COLOR_SURF)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
color_map: dict = json.loads(args.color_map)
|
||||
|
||||
from OCP.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCP.TDocStd import TDocStd_Document
|
||||
from OCP.XCAFApp import XCAFApp_Application
|
||||
from OCP.XCAFDoc import XCAFDoc_DocumentTool
|
||||
from OCP.TCollection import TCollection_ExtendedString, TCollection_AsciiString
|
||||
from OCP.TDF import TDF_LabelSequence
|
||||
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCP.IFSelect import IFSelect_RetDone
|
||||
from OCP.Message import Message_ProgressRange
|
||||
|
||||
# --- Init XDE document ---
|
||||
app = XCAFApp_Application.GetApplication_s()
|
||||
doc = TDocStd_Document(TCollection_ExtendedString("MDTV-CAF"))
|
||||
app.InitDocument(doc)
|
||||
|
||||
# --- Read STEP into XDE (preserves part names + embedded colors) ---
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetNameMode(True)
|
||||
reader.SetColorMode(True)
|
||||
reader.SetLayerMode(True)
|
||||
status = reader.ReadFile(args.step_path)
|
||||
if status != IFSelect_RetDone:
|
||||
print(f"ERROR: STEPCAFControl_Reader failed (status={status})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
reader.Transfer(doc)
|
||||
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool_s(doc.Main())
|
||||
color_tool = XCAFDoc_DocumentTool.ColorTool_s(doc.Main())
|
||||
|
||||
# --- Tessellate all free shapes ---
|
||||
free_labels = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(free_labels)
|
||||
print(f"Found {free_labels.Length()} root shape(s), tessellating "
|
||||
f"(linear={args.linear_deflection}mm, angular={args.angular_deflection}rad) …")
|
||||
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
shape = shape_tool.GetShape_s(free_labels.Value(i))
|
||||
if not shape.IsNull():
|
||||
BRepMesh_IncrementalMesh(
|
||||
shape,
|
||||
args.linear_deflection,
|
||||
False, # isRelative
|
||||
args.angular_deflection,
|
||||
True, # isInParallel
|
||||
)
|
||||
|
||||
# --- Apply colors ---
|
||||
if color_map:
|
||||
_apply_color_map(shape_tool, color_tool, free_labels, color_map)
|
||||
print(f"Applied color_map ({len(color_map)} entries)")
|
||||
else:
|
||||
_apply_palette_colors(shape_tool, color_tool, free_labels)
|
||||
print("Applied palette colors (no color_map provided)")
|
||||
|
||||
# --- Scale shapes mm → m before GLB export ---
|
||||
# RWMesh_CoordinateSystemConverter is not wrapped in OCP Python bindings.
|
||||
# Pre-scale each free shape by 0.001 (mm → m) using BRepBuilderAPI_Transform.
|
||||
from OCP.gp import gp_Trsf
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
|
||||
trsf = gp_Trsf()
|
||||
trsf.SetScaleFactor(0.001)
|
||||
|
||||
for i in range(1, free_labels.Length() + 1):
|
||||
label = free_labels.Value(i)
|
||||
orig_shape = shape_tool.GetShape_s(label)
|
||||
if not orig_shape.IsNull():
|
||||
scaled = BRepBuilderAPI_Transform(orig_shape, trsf, True).Shape()
|
||||
shape_tool.SetShape(label, scaled)
|
||||
|
||||
print("Shapes scaled mm → m")
|
||||
|
||||
# --- Export GLB via RWGltf_CafWriter ---
|
||||
from OCP.RWGltf import RWGltf_CafWriter
|
||||
|
||||
writer = RWGltf_CafWriter(TCollection_AsciiString(args.output_path), True) # True = binary GLB
|
||||
# Z-up → Y-up rotation is applied by RWGltf_CafWriter by default (OCC 7.6+).
|
||||
|
||||
# Perform export
|
||||
try:
|
||||
from OCP.TColStd import TColStd_IndexedDataMapOfStringString
|
||||
metadata = TColStd_IndexedDataMapOfStringString()
|
||||
ok = writer.Perform(doc, metadata, Message_ProgressRange())
|
||||
except TypeError:
|
||||
# Older API without metadata dict
|
||||
ok = writer.Perform(doc, Message_ProgressRange())
|
||||
|
||||
out = Path(args.output_path)
|
||||
if not ok or not out.exists() or out.stat().st_size == 0:
|
||||
print(f"ERROR: RWGltf_CafWriter.Perform returned ok={ok}, file exists={out.exists()}",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"GLB exported: {out.name} ({out.stat().st_size // 1024} KB)")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -87,23 +87,7 @@ def _apply_smooth(part_obj, angle_deg):
|
||||
import re as _re
|
||||
|
||||
|
||||
def _scale_mm_to_m(parts):
|
||||
"""Scale imported STL objects from mm to Blender metres (×0.001).
|
||||
|
||||
STEP/STL coordinates are in mm; Blender's default unit is metres.
|
||||
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
|
||||
relative to any template environment designed in metric units.
|
||||
"""
|
||||
if not parts:
|
||||
return
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for p in parts:
|
||||
p.scale = (0.001, 0.001, 0.001)
|
||||
p.location *= 0.001
|
||||
p.select_set(True)
|
||||
bpy.context.view_layer.objects.active = parts[0]
|
||||
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
|
||||
print(f"[still_render] scaled {len(parts)} parts mm→m (×0.001)")
|
||||
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
|
||||
|
||||
|
||||
def _apply_rotation(parts, rx, ry, rz):
|
||||
@@ -209,74 +193,24 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
|
||||
def _import_stl(stl_file):
|
||||
"""Import STL into Blender, using per-part STLs if available.
|
||||
def _import_glb(glb_file):
|
||||
"""Import OCC-generated GLB into Blender.
|
||||
|
||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
||||
- Fallback: imports combined STL and splits by loose geometry.
|
||||
|
||||
Returns list of Blender mesh objects, centred at origin.
|
||||
OCC exports one mesh object per STEP part, already in metres.
|
||||
Returns list of Blender mesh objects, centred at world origin.
|
||||
"""
|
||||
stl_dir = os.path.dirname(stl_file)
|
||||
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
|
||||
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
|
||||
manifest_path = os.path.join(parts_dir, "manifest.json")
|
||||
|
||||
parts = []
|
||||
|
||||
if os.path.isfile(manifest_path):
|
||||
# ── Per-part mode ────────────────────────────────────────────────
|
||||
try:
|
||||
with open(manifest_path, "r") as f:
|
||||
manifest = json.loads(f.read())
|
||||
part_entries = manifest.get("parts", [])
|
||||
except Exception as e:
|
||||
print(f"[still_render] WARNING: failed to read manifest: {e}")
|
||||
part_entries = []
|
||||
|
||||
if part_entries:
|
||||
for entry in part_entries:
|
||||
part_file = os.path.join(parts_dir, entry["file"])
|
||||
part_name = entry["name"]
|
||||
if not os.path.isfile(part_file):
|
||||
print(f"[still_render] WARNING: part STL missing: {part_file}")
|
||||
continue
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
bpy.ops.wm.stl_import(filepath=part_file)
|
||||
imported = bpy.context.selected_objects
|
||||
if imported:
|
||||
obj = imported[0]
|
||||
obj.name = part_name
|
||||
if obj.data:
|
||||
obj.data.name = part_name
|
||||
parts.append(obj)
|
||||
bpy.ops.import_scene.gltf(filepath=glb_file)
|
||||
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||
|
||||
if parts:
|
||||
print(f"[still_render] imported {len(parts)} named parts from per-part STLs")
|
||||
|
||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
||||
if not parts:
|
||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
||||
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
|
||||
if obj is None:
|
||||
print(f"ERROR: No objects imported from {stl_file}")
|
||||
print(f"ERROR: No mesh objects imported from {glb_file}")
|
||||
sys.exit(1)
|
||||
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
print(f"[still_render] imported {len(parts)} part(s) from GLB: "
|
||||
f"{[p.name for p in parts[:5]]}")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
parts = list(bpy.context.selected_objects)
|
||||
print(f"[still_render] fallback: separated into {len(parts)} part(s)")
|
||||
return parts
|
||||
|
||||
# ── Centre per-part imports at origin (combined bbox) ────────────────
|
||||
# Centre combined bbox at world origin
|
||||
all_corners = []
|
||||
for p in parts:
|
||||
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
||||
@@ -376,7 +310,7 @@ def main():
|
||||
argv = sys.argv
|
||||
args = argv[argv.index("--") + 1:]
|
||||
|
||||
stl_path = args[0]
|
||||
glb_path = args[0]
|
||||
output_path = args[1]
|
||||
width = int(args[2])
|
||||
height = int(args[3])
|
||||
@@ -460,10 +394,8 @@ def main():
|
||||
# Find or create target collection
|
||||
target_col = _ensure_collection(target_collection)
|
||||
|
||||
# Import and split STL
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
# Import OCC GLB (already in metres, one object per STEP part)
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation (before camera/bbox calculations)
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
# Apply OCC topology-based shading overrides
|
||||
@@ -562,9 +494,7 @@ def main():
|
||||
needs_auto_camera = True
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation (before camera/bbox calculations)
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
# Apply OCC topology-based shading overrides
|
||||
@@ -839,7 +769,7 @@ def main():
|
||||
draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255))
|
||||
|
||||
# Model name strip at bottom
|
||||
model_name = os.path.splitext(os.path.basename(stl_path))[0]
|
||||
model_name = os.path.splitext(os.path.basename(glb_path))[0]
|
||||
label_h = max(20, H // 20)
|
||||
img.alpha_composite(
|
||||
Image.new("RGBA", (W, label_h), (30, 30, 30, 180)),
|
||||
|
||||
@@ -138,23 +138,7 @@ def _set_fcurves_linear(action):
|
||||
kp.interpolation = 'LINEAR'
|
||||
|
||||
|
||||
def _scale_mm_to_m(parts):
|
||||
"""Scale imported STL objects from mm to Blender metres (×0.001).
|
||||
|
||||
STEP/STL coordinates are in mm; Blender's default unit is metres.
|
||||
Without scaling a 50 mm part appears as 50 m inside Blender — way too large
|
||||
relative to any template environment designed in metric units.
|
||||
"""
|
||||
if not parts:
|
||||
return
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for p in parts:
|
||||
p.scale = (0.001, 0.001, 0.001)
|
||||
p.location *= 0.001
|
||||
p.select_set(True)
|
||||
bpy.context.view_layer.objects.active = parts[0]
|
||||
bpy.ops.object.transform_apply(scale=True, location=False, rotation=False)
|
||||
print(f"[turntable_render] scaled {len(parts)} parts mm→m (×0.001)")
|
||||
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
|
||||
|
||||
|
||||
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
|
||||
@@ -179,74 +163,24 @@ def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
|
||||
obj.data.auto_smooth_angle = threshold_rad
|
||||
|
||||
|
||||
def _import_stl(stl_file):
|
||||
"""Import STL into Blender, using per-part STLs if available.
|
||||
def _import_glb(glb_file):
|
||||
"""Import OCC-generated GLB into Blender.
|
||||
|
||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
||||
- Fallback: imports combined STL and splits by loose geometry.
|
||||
|
||||
Returns list of Blender mesh objects, centred at origin.
|
||||
OCC exports one mesh object per STEP part, already in metres.
|
||||
Returns list of Blender mesh objects, centred at world origin.
|
||||
"""
|
||||
stl_dir = os.path.dirname(stl_file)
|
||||
stl_stem = os.path.splitext(os.path.basename(stl_file))[0]
|
||||
parts_dir = os.path.join(stl_dir, stl_stem + "_parts")
|
||||
manifest_path = os.path.join(parts_dir, "manifest.json")
|
||||
|
||||
parts = []
|
||||
|
||||
if os.path.isfile(manifest_path):
|
||||
# ── Per-part mode ────────────────────────────────────────────────
|
||||
try:
|
||||
with open(manifest_path, "r") as f:
|
||||
manifest = json.loads(f.read())
|
||||
part_entries = manifest.get("parts", [])
|
||||
except Exception as e:
|
||||
print(f"[turntable_render] WARNING: failed to read manifest: {e}")
|
||||
part_entries = []
|
||||
|
||||
if part_entries:
|
||||
for entry in part_entries:
|
||||
part_file = os.path.join(parts_dir, entry["file"])
|
||||
part_name = entry["name"]
|
||||
if not os.path.isfile(part_file):
|
||||
print(f"[turntable_render] WARNING: part STL missing: {part_file}")
|
||||
continue
|
||||
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
bpy.ops.wm.stl_import(filepath=part_file)
|
||||
imported = bpy.context.selected_objects
|
||||
if imported:
|
||||
obj = imported[0]
|
||||
obj.name = part_name
|
||||
if obj.data:
|
||||
obj.data.name = part_name
|
||||
parts.append(obj)
|
||||
bpy.ops.import_scene.gltf(filepath=glb_file)
|
||||
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||
|
||||
if parts:
|
||||
print(f"[turntable_render] imported {len(parts)} named parts from per-part STLs")
|
||||
|
||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
||||
if not parts:
|
||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
||||
obj = bpy.context.selected_objects[0] if bpy.context.selected_objects else None
|
||||
if obj is None:
|
||||
print(f"ERROR: No objects imported from {stl_file}")
|
||||
print(f"ERROR: No mesh objects imported from {glb_file}")
|
||||
sys.exit(1)
|
||||
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
||||
obj.location = (0.0, 0.0, 0.0)
|
||||
print(f"[turntable_render] imported {len(parts)} part(s) from GLB: "
|
||||
f"{[p.name for p in parts[:5]]}")
|
||||
|
||||
bpy.ops.object.mode_set(mode='EDIT')
|
||||
bpy.ops.mesh.separate(type='LOOSE')
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
parts = list(bpy.context.selected_objects)
|
||||
print(f"[turntable_render] fallback: separated into {len(parts)} part(s)")
|
||||
return parts
|
||||
|
||||
# ── Centre per-part imports at origin (combined bbox) ────────────────
|
||||
# Centre combined bbox at world origin
|
||||
all_corners = []
|
||||
for p in parts:
|
||||
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
||||
@@ -347,7 +281,7 @@ def main():
|
||||
# Everything after "--" is our args
|
||||
args = argv[argv.index("--") + 1:]
|
||||
|
||||
stl_path = args[0]
|
||||
glb_path = args[0]
|
||||
frames_dir = args[1]
|
||||
frame_count = int(args[2])
|
||||
degrees = int(args[3])
|
||||
@@ -427,10 +361,8 @@ def main():
|
||||
# Find or create target collection
|
||||
target_col = _ensure_collection(target_collection)
|
||||
|
||||
# Import and split STL
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
# Import OCC GLB (already in metres, one object per STEP part)
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation before material/camera setup
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
# Apply OCC topology-based shading overrides
|
||||
@@ -508,9 +440,7 @@ def main():
|
||||
needs_auto_camera = True
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
parts = _import_stl(stl_path)
|
||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
||||
_scale_mm_to_m(parts)
|
||||
parts = _import_glb(glb_path)
|
||||
# Apply render position rotation before material/camera setup
|
||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||
# Apply OCC topology-based shading overrides
|
||||
|
||||
Reference in New Issue
Block a user