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.
|
**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.
|
**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
|
### 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.
|
**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()`.
|
**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"}
|
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)
|
@router.post("/settings/generate-missing-geometry-glbs", status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def generate_missing_stls(
|
async def generate_missing_geometry_glbs(
|
||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Queue STL generation for every quality missing from each completed CAD file."""
|
"""Queue geometry GLB generation for every completed CAD file that has no gltf_geometry MediaAsset."""
|
||||||
from pathlib import Path as _Path
|
import uuid as _uuid
|
||||||
|
from app.domains.media.models import MediaAsset, MediaAssetType
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
|
select(CadFile).where(CadFile.processing_status == ProcessingStatus.completed)
|
||||||
)
|
)
|
||||||
cad_files = result.scalars().all()
|
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
|
queued = 0
|
||||||
for cad_file in cad_files:
|
for cad_file in cad_files:
|
||||||
if not cad_file.stored_path:
|
if not cad_file.stored_path:
|
||||||
continue
|
continue
|
||||||
step = _Path(cad_file.stored_path)
|
if cad_file.id not in existing_ids:
|
||||||
for quality in ("low", "high"):
|
generate_gltf_geometry_task.delay(str(cad_file.id))
|
||||||
if not (step.parent / f"{step.stem}_{quality}.stl").exists():
|
|
||||||
generate_stl_cache.delay(str(cad_file.id), quality)
|
|
||||||
queued += 1
|
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)
|
@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)
|
@router.post("/{id}/generate-gltf-geometry", status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def generate_gltf_geometry(
|
async def generate_gltf_geometry(
|
||||||
id: uuid.UUID,
|
id: uuid.UUID,
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
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'.
|
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"):
|
if user.role.value not in ("admin", "project_manager"):
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||||
@@ -342,20 +280,34 @@ async def generate_gltf_geometry(
|
|||||||
if not cad.stored_path:
|
if not cad.stored_path:
|
||||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
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
|
from app.tasks.step_tasks import generate_gltf_geometry_task
|
||||||
task = generate_gltf_geometry_task.delay(str(id))
|
task = generate_gltf_geometry_task.delay(str(id))
|
||||||
return {"status": "queued", "task_id": task.id, "cad_file_id": 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(
|
@router.post(
|
||||||
"/{id}/regenerate-thumbnail",
|
"/{id}/regenerate-thumbnail",
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
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_parsed_objects = product.cad_parsed_objects
|
||||||
out.cad_mesh_attributes = product.cad_file.mesh_attributes if product.cad_file else None
|
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.render_image_url = _best_render_url(product, priority or ["latest_render", "cad_thumbnail"])
|
||||||
out.stl_cached = _stl_cached_qualities(product)
|
|
||||||
return out
|
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]:
|
async def _load_thumbnail_priority(db: AsyncSession) -> list[str]:
|
||||||
"""Read product_thumbnail_priority from system_settings.
|
"""Read product_thumbnail_priority from system_settings.
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ class ProductOut(BaseModel):
|
|||||||
thumbnail_url: str | None = None
|
thumbnail_url: str | None = None
|
||||||
render_image_url: str | None = None
|
render_image_url: str | None = None
|
||||||
processing_status: str | None = None
|
processing_status: str | None = None
|
||||||
stl_cached: list[str] = []
|
|
||||||
cad_parsed_objects: list[str] | None = None
|
cad_parsed_objects: list[str] | None = None
|
||||||
cad_mesh_attributes: dict | None = None
|
cad_mesh_attributes: dict | None = None
|
||||||
arbeitspaket: str | None = None
|
arbeitspaket: str | None = None
|
||||||
|
|||||||
@@ -193,9 +193,8 @@ def render_turntable_task(
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from app.services.render_blender import (
|
import sys
|
||||||
find_blender, convert_step_to_stl, export_per_part_stls
|
from app.services.render_blender import find_blender
|
||||||
)
|
|
||||||
|
|
||||||
blender_bin = find_blender()
|
blender_bin = find_blender()
|
||||||
if not blender_bin:
|
if not blender_bin:
|
||||||
@@ -208,27 +207,25 @@ def render_turntable_task(
|
|||||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||||
turntable_script = scripts_dir / "turntable_render.py"
|
turntable_script = scripts_dir / "turntable_render.py"
|
||||||
|
|
||||||
# STL conversion — try MinIO cache first, then convert locally
|
# GLB generation via OCC (replaces STL intermediary)
|
||||||
stl_path = step.parent / f"{step.stem}_{stl_quality}.stl"
|
linear_deflection = 0.3 if stl_quality == "low" else 0.05
|
||||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
angular_deflection = 0.3 if stl_quality == "low" else 0.1
|
||||||
try:
|
glb_path = step.parent / f"{step.stem}_{stl_quality}.glb"
|
||||||
from app.domains.products.cache_service import compute_step_hash, check_stl_cache
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||||
step_hash = compute_step_hash(str(step))
|
occ_script = scripts_dir / "export_step_to_gltf.py"
|
||||||
cached = check_stl_cache(step_hash, stl_quality)
|
occ_cmd = [
|
||||||
if cached:
|
sys.executable, str(occ_script),
|
||||||
stl_path.write_bytes(cached)
|
"--step_path", str(step),
|
||||||
logger.info("STL restored from MinIO cache: %s", stl_path.name)
|
"--output_path", str(glb_path),
|
||||||
else:
|
"--linear_deflection", str(linear_deflection),
|
||||||
convert_step_to_stl(step, stl_path, stl_quality)
|
"--angular_deflection", str(angular_deflection),
|
||||||
except Exception as exc:
|
]
|
||||||
logger.warning("MinIO cache check failed (non-fatal): %s — falling back to conversion", exc)
|
occ_result = subprocess.run(occ_cmd, capture_output=True, text=True, timeout=120)
|
||||||
convert_step_to_stl(step, stl_path, stl_quality)
|
if occ_result.returncode != 0:
|
||||||
parts_dir = step.parent / f"{step.stem}_{stl_quality}_parts"
|
raise RuntimeError(
|
||||||
if not (parts_dir / "manifest.json").exists():
|
f"export_step_to_gltf.py failed:\n{occ_result.stderr[-500:]}"
|
||||||
try:
|
)
|
||||||
export_per_part_stls(step, parts_dir, stl_quality)
|
logger.info("render_turntable_task: GLB generated: %s", glb_path.name)
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("per-part export non-fatal: %s", exc)
|
|
||||||
|
|
||||||
# Build turntable render arguments
|
# Build turntable render arguments
|
||||||
frames_dir = out_dir / "frames"
|
frames_dir = out_dir / "frames"
|
||||||
@@ -238,7 +235,7 @@ def render_turntable_task(
|
|||||||
blender_bin, "--background",
|
blender_bin, "--background",
|
||||||
"--python", str(turntable_script),
|
"--python", str(turntable_script),
|
||||||
"--",
|
"--",
|
||||||
str(stl_path),
|
str(glb_path),
|
||||||
str(frames_dir),
|
str(frames_dir),
|
||||||
output_name,
|
output_name,
|
||||||
str(width), str(height),
|
str(width), str(height),
|
||||||
@@ -463,93 +460,40 @@ def render_order_line_still_task(self, order_line_id: str, **params) -> dict:
|
|||||||
max_retries=1,
|
max_retries=1,
|
||||||
)
|
)
|
||||||
def export_gltf_for_order_line_task(self, order_line_id: str) -> dict:
|
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
|
Publishes a MediaAsset with asset_type='gltf_geometry'.
|
||||||
'gltf_production' (when an asset library is applied).
|
|
||||||
Requires the STL low-quality cache to exist.
|
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
|
step_path_str, cad_file_id = _resolve_step_path_for_order_line(order_line_id)
|
||||||
if not step_path_str:
|
if not step_path_str:
|
||||||
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
||||||
|
|
||||||
step = Path(step_path_str)
|
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"
|
output_path = step.parent / f"{step.stem}_geometry.glb"
|
||||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
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:
|
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 = [
|
cmd = [
|
||||||
blender_bin, "--background",
|
sys.executable, str(occ_script),
|
||||||
"--python", str(export_script),
|
"--step_path", str(step),
|
||||||
"--",
|
|
||||||
"--stl_path", str(stl_path),
|
|
||||||
"--output_path", str(output_path),
|
"--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=120)
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(
|
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))
|
publish_asset.delay(order_line_id, "gltf_geometry", str(output_path))
|
||||||
logger.info("export_gltf_for_order_line_task completed via Blender: %s", output_path.name)
|
logger.info("export_gltf_for_order_line_task completed via OCC: %s", output_path.name)
|
||||||
return {"glb_path": str(output_path), "method": "blender"}
|
return {"glb_path": str(output_path), "method": "occ"}
|
||||||
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"}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
|
logger.error("export_gltf_for_order_line_task failed for %s: %s", order_line_id, exc)
|
||||||
raise self.retry(exc=exc, countdown=15)
|
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}")
|
raise RuntimeError(f"Cannot resolve STEP path for order_line {order_line_id}")
|
||||||
|
|
||||||
step = Path(step_path_str)
|
step = Path(step_path_str)
|
||||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
# Use geometry GLB as input (generate if missing)
|
||||||
if not stl_path.exists():
|
glb_path = step.parent / f"{step.stem}_geometry.glb"
|
||||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
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"
|
output_path = step.parent / f"{step.stem}_production.blend"
|
||||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
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",
|
blender_bin, "--background",
|
||||||
"--python", str(export_script),
|
"--python", str(export_script),
|
||||||
"--",
|
"--",
|
||||||
"--stl_path", str(stl_path),
|
"--glb_path", str(glb_path),
|
||||||
"--output_path", str(output_path),
|
"--output_path", str(output_path),
|
||||||
"--asset_library_blend", asset_lib_path,
|
"--asset_library_blend", asset_lib_path,
|
||||||
"--material_map", json.dumps(mat_map),
|
"--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:
|
if not product or not product.cad_file_id:
|
||||||
return None, None, None
|
return None, None, None
|
||||||
cad = s.execute(sql_select(CadFile).where(CadFile.id == product.cad_file_id)).scalar_one_or_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
|
# Resolve asset library blend path
|
||||||
try:
|
try:
|
||||||
@@ -681,24 +636,24 @@ def apply_asset_library_materials_task(self, order_line_id: str, asset_library_i
|
|||||||
blend_path = None
|
blend_path = None
|
||||||
|
|
||||||
mat_map = {m.get("part_name", ""): m.get("material", "") for m in (product.cad_part_materials or [])}
|
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()
|
result = _inner()
|
||||||
if result is None or result[0] is None:
|
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)
|
logger.warning("apply_asset_library_materials_task: could not resolve paths for %s", order_line_id)
|
||||||
return {"status": "skipped"}
|
return {"status": "skipped"}
|
||||||
|
|
||||||
stl_path, blend_path, mat_map = result
|
glb_path, blend_path, mat_map = result
|
||||||
if not stl_path or not Path(stl_path).exists():
|
if not glb_path or not Path(glb_path).exists():
|
||||||
logger.warning("STL not found for %s", order_line_id)
|
logger.warning("Geometry GLB not found for %s", order_line_id)
|
||||||
return {"status": "skipped", "reason": "stl_not_found"}
|
return {"status": "skipped", "reason": "glb_not_found"}
|
||||||
|
|
||||||
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
scripts_dir = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||||
script = scripts_dir / "asset_library.py"
|
script = scripts_dir / "asset_library.py"
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
blender_bin, "--background", "--python", str(script), "--",
|
blender_bin, "--background", "--python", str(script), "--",
|
||||||
"--stl_path", stl_path,
|
"--glb_path", glb_path,
|
||||||
"--asset_library_blend", blend_path or "",
|
"--asset_library_blend", blend_path or "",
|
||||||
"--material_map", json.dumps(mat_map),
|
"--material_map", json.dumps(mat_map),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,24 +17,39 @@ logger = logging.getLogger(__name__)
|
|||||||
MIN_BLENDER_VERSION = (5, 0, 1)
|
MIN_BLENDER_VERSION = (5, 0, 1)
|
||||||
|
|
||||||
|
|
||||||
def _stl_from_cache_or_convert(step_path: Path, stl_path: Path, quality: str) -> None:
|
def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None:
|
||||||
"""Try MinIO cache first, then fall back to local STEP→STL conversion."""
|
"""Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed).
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Local conversion
|
quality: "low" → coarser mesh (~0.3 mm deflection, fast)
|
||||||
from app.services.step_processor import convert_step_to_stl
|
"high" → finer mesh (~0.05 mm deflection, slower)
|
||||||
logger.info("STL cache miss — converting: %s", step_path.name)
|
"""
|
||||||
convert_step_to_stl(step_path, stl_path, quality)
|
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:
|
def find_blender() -> str:
|
||||||
@@ -51,127 +66,6 @@ def is_blender_available() -> bool:
|
|||||||
return bool(find_blender())
|
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(
|
def render_still(
|
||||||
step_path: Path,
|
step_path: Path,
|
||||||
output_path: Path,
|
output_path: Path,
|
||||||
@@ -202,7 +96,7 @@ def render_still(
|
|||||||
denoising_use_gpu: str = "",
|
denoising_use_gpu: str = "",
|
||||||
mesh_attributes: dict | None = None,
|
mesh_attributes: dict | None = None,
|
||||||
) -> dict:
|
) -> 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.
|
Returns a dict with timing, sizes, engine_used, and log_lines.
|
||||||
Raises RuntimeError on failure.
|
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"
|
script_path = Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "blender_render.py"
|
||||||
if not script_path.exists():
|
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"
|
alt = Path(__file__).parent.parent.parent.parent / "render-worker" / "scripts" / "blender_render.py"
|
||||||
if alt.exists():
|
if alt.exists():
|
||||||
script_path = alt
|
script_path = alt
|
||||||
@@ -224,24 +117,16 @@ def render_still(
|
|||||||
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|
||||||
# 1. STL conversion (cadquery)
|
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
|
||||||
|
|
||||||
t_stl = time.monotonic()
|
t_glb = time.monotonic()
|
||||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||||
else:
|
else:
|
||||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
glb_size_bytes = glb_path.stat().st_size if glb_path.exists() else 0
|
||||||
|
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||||
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)
|
|
||||||
|
|
||||||
# 2. Blender render
|
# 2. Blender render
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -263,7 +148,7 @@ def render_still(
|
|||||||
"--background",
|
"--background",
|
||||||
"--python", str(script_path),
|
"--python", str(script_path),
|
||||||
"--",
|
"--",
|
||||||
str(stl_path),
|
str(glb_path),
|
||||||
str(output_path),
|
str(output_path),
|
||||||
str(width), str(height),
|
str(width), str(height),
|
||||||
eng, str(samples), str(smooth_angle),
|
eng, str(samples), str(smooth_angle),
|
||||||
@@ -332,22 +217,13 @@ def render_still(
|
|||||||
|
|
||||||
render_duration_s = round(time.monotonic() - t_render, 2)
|
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 {
|
return {
|
||||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
"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,
|
"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,
|
"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,
|
"engine_used": engine_used,
|
||||||
"log_lines": log_lines,
|
"log_lines": log_lines,
|
||||||
}
|
}
|
||||||
@@ -407,24 +283,15 @@ def render_turntable_to_file(
|
|||||||
|
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|
||||||
# 1. STL conversion
|
# 1. GLB conversion (OCC — replaces cadquery STL)
|
||||||
stl_path = step_path.parent / f"{step_path.stem}_{stl_quality}.stl"
|
glb_path = step_path.parent / f"{step_path.stem}_thumbnail.glb"
|
||||||
parts_dir = step_path.parent / f"{step_path.stem}_{stl_quality}_parts"
|
|
||||||
|
|
||||||
t_stl = time.monotonic()
|
t_glb = time.monotonic()
|
||||||
if not stl_path.exists() or stl_path.stat().st_size == 0:
|
if not glb_path.exists() or glb_path.stat().st_size == 0:
|
||||||
_stl_from_cache_or_convert(step_path, stl_path, stl_quality)
|
_glb_from_step(step_path, glb_path, quality=stl_quality)
|
||||||
else:
|
else:
|
||||||
logger.info("STL local hit: %s (%d KB)", stl_path.name, stl_path.stat().st_size // 1024)
|
logger.info("GLB local hit: %s (%d KB)", glb_path.name, glb_path.stat().st_size // 1024)
|
||||||
stl_size_bytes = stl_path.stat().st_size if stl_path.exists() else 0
|
glb_duration_s = round(time.monotonic() - t_glb, 2)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# 2. Render frames with Blender
|
# 2. Render frames with Blender
|
||||||
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
|
frames_dir = output_path.parent / f"_frames_{output_path.stem}"
|
||||||
@@ -439,7 +306,7 @@ def render_turntable_to_file(
|
|||||||
"--background",
|
"--background",
|
||||||
"--python", str(script_path),
|
"--python", str(script_path),
|
||||||
"--",
|
"--",
|
||||||
str(stl_path),
|
str(glb_path),
|
||||||
str(frames_dir),
|
str(frames_dir),
|
||||||
str(frame_count),
|
str(frame_count),
|
||||||
"360", # degrees
|
"360", # degrees
|
||||||
@@ -554,10 +421,10 @@ def render_turntable_to_file(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"total_duration_s": round(time.monotonic() - t0, 2),
|
"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,
|
"render_duration_s": render_duration_s,
|
||||||
"ffmpeg_duration_s": ffmpeg_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,
|
"output_size_bytes": output_path.stat().st_size if output_path.exists() else 0,
|
||||||
"frame_count": len(frame_files),
|
"frame_count": len(frame_files),
|
||||||
"engine_used": engine,
|
"engine_used": engine,
|
||||||
|
|||||||
+208
-165
@@ -1,6 +1,5 @@
|
|||||||
"""Celery tasks for STEP file processing and thumbnail generation."""
|
"""Celery tasks for STEP file processing and thumbnail generation."""
|
||||||
import logging
|
import logging
|
||||||
import struct
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from app.tasks.celery_app import celery_app
|
from app.tasks.celery_app import celery_app
|
||||||
from app.core.task_logs import log_task_event
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _bbox_from_stl(stl_path: str) -> dict | None:
|
def _bbox_from_glb(glb_path: str) -> dict | None:
|
||||||
"""Extract bounding box from a cached binary STL file.
|
"""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.
|
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:
|
try:
|
||||||
import numpy as np
|
import trimesh
|
||||||
p = Path(stl_path)
|
p = Path(glb_path)
|
||||||
if not p.exists() or p.stat().st_size < 84:
|
if not p.exists():
|
||||||
return None
|
return None
|
||||||
with p.open("rb") as f:
|
scene = trimesh.load(str(p), force="scene")
|
||||||
f.seek(80) # skip 80-byte header
|
bounds = getattr(scene, "bounds", None)
|
||||||
n = struct.unpack("<I", f.read(4))[0]
|
if bounds is None:
|
||||||
if n == 0:
|
|
||||||
return None
|
return None
|
||||||
raw = f.read(n * 50) # 50 bytes per triangle
|
mins, maxs = bounds
|
||||||
# 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)
|
|
||||||
dims = maxs - mins
|
dims = maxs - mins
|
||||||
return {
|
return {
|
||||||
"dimensions_mm": {
|
"dimensions_mm": {
|
||||||
"x": round(float(dims[0]), 2),
|
"x": round(float(dims[0]) * 1000, 2),
|
||||||
"y": round(float(dims[1]), 2),
|
"y": round(float(dims[1]) * 1000, 2),
|
||||||
"z": round(float(dims[2]), 2),
|
"z": round(float(dims[2]) * 1000, 2),
|
||||||
},
|
},
|
||||||
"bbox_center_mm": {
|
"bbox_center_mm": {
|
||||||
"x": round(float((mins[0] + maxs[0]) / 2), 2),
|
"x": round(float((mins[0] + maxs[0]) / 2) * 1000, 2),
|
||||||
"y": round(float((mins[1] + maxs[1]) / 2), 2),
|
"y": round(float((mins[1] + maxs[1]) / 2) * 1000, 2),
|
||||||
"z": round(float((mins[2] + maxs[2]) / 2), 2),
|
"z": round(float((mins[2] + maxs[2]) / 2) * 1000, 2),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
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
|
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}")
|
logger.error(f"Thumbnail render failed for {cad_file_id}: {exc}")
|
||||||
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
raise self.retry(exc=exc, countdown=30, max_retries=2)
|
||||||
|
|
||||||
# Extract bounding box from the STL that was just cached by the renderer.
|
# Extract bounding box from the thumbnail GLB generated by the renderer.
|
||||||
# STL binary parsing is near-instant (numpy min/max) vs re-parsing the STEP file.
|
# GLB bbox via trimesh is fast and avoids re-parsing the STEP file.
|
||||||
# Falls back to cadquery STEP re-parse if STL is not found.
|
# Falls back to cadquery STEP re-parse if GLB is not found.
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session
|
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"):
|
if _step_path and not (_cad2.mesh_attributes or {}).get("dimensions_mm"):
|
||||||
_step = Path(_step_path)
|
_step = Path(_step_path)
|
||||||
_stl = _step.parent / f"{_step.stem}_low.stl"
|
_glb = _step.parent / f"{_step.stem}_thumbnail.glb"
|
||||||
bbox_data = _bbox_from_stl(str(_stl)) or _bbox_from_step_cadquery(_step_path)
|
bbox_data = _bbox_from_glb(str(_glb)) or _bbox_from_step_cadquery(_step_path)
|
||||||
if bbox_data:
|
if bbox_data:
|
||||||
_eng2 = create_engine(_sync_url2)
|
_eng2 = create_engine(_sync_url2)
|
||||||
with Session(_eng2) as _sess2:
|
with Session(_eng2) as _sess2:
|
||||||
@@ -295,6 +287,13 @@ def render_step_thumbnail(self, cad_file_id: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("WebSocket publish for CAD complete skipped (non-fatal)")
|
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")
|
@celery_app.task(name="app.tasks.step_tasks.reextract_cad_metadata", queue="thumbnail_rendering")
|
||||||
def reextract_cad_metadata(cad_file_id: str):
|
def reextract_cad_metadata(cad_file_id: str):
|
||||||
@@ -321,8 +320,8 @@ def reextract_cad_metadata(cad_file_id: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
p = Path(step_path)
|
p = Path(step_path)
|
||||||
stl_path = p.parent / f"{p.stem}_low.stl"
|
glb_path = p.parent / f"{p.stem}_thumbnail.glb"
|
||||||
patch = _bbox_from_stl(str(stl_path)) or _bbox_from_step_cadquery(step_path)
|
patch = _bbox_from_glb(str(glb_path)) or _bbox_from_step_cadquery(step_path)
|
||||||
if patch:
|
if patch:
|
||||||
with Session(eng) as session:
|
with Session(eng) as session:
|
||||||
cad_file = session.get(CadFile, cad_file_id)
|
cad_file = session.get(CadFile, cad_file_id)
|
||||||
@@ -342,72 +341,22 @@ def reextract_cad_metadata(cad_file_id: str):
|
|||||||
eng.dispose()
|
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)
|
@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):
|
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:
|
Pipeline:
|
||||||
1. Reads sharp_edge_midpoints from cad_file.mesh_attributes (from OCC extraction)
|
1. Reads STEP file directly (no STL needed)
|
||||||
2. Resolves material_map via alias lookup (part_name → SCHAEFFLER library material)
|
2. Builds color_map from product.cad_part_materials (hex colors)
|
||||||
3. Runs Blender headless with export_gltf.py: STL → GLB with library materials + sharp edges
|
3. Runs export_step_to_gltf.py (Python/OCP): STEP → GLB with per-part colors
|
||||||
4. Falls back to trimesh (geometry-only, no materials) if Blender is unavailable
|
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 json as _json
|
||||||
import os as _os
|
import os as _os
|
||||||
import subprocess as _subprocess
|
import subprocess as _subprocess
|
||||||
|
import sys as _sys
|
||||||
from pathlib import Path as _Path
|
from pathlib import Path as _Path
|
||||||
from sqlalchemy import create_engine, select as _select
|
from sqlalchemy import create_engine, select as _select
|
||||||
from sqlalchemy.orm import Session
|
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)
|
logger.error("generate_gltf_geometry_task: no stored_path for %s", cad_file_id)
|
||||||
return
|
return
|
||||||
step_path_str = cad_file.stored_path
|
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
|
from app.domains.products.models import Product
|
||||||
product = session.execute(
|
product = session.execute(
|
||||||
_select(Product).where(Product.cad_file_id == cad_file.id)
|
_select(Product).where(Product.cad_file_id == cad_file.id)
|
||||||
).scalar_one_or_none()
|
).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:
|
if product and product.cad_part_materials:
|
||||||
for entry in product.cad_part_materials:
|
for entry in product.cad_part_materials:
|
||||||
part_name = entry.get("part_name") or entry.get("name", "")
|
part_name = entry.get("part_name") or entry.get("name", "")
|
||||||
mat_name = entry.get("material_name") or entry.get("material", "")
|
hex_color = entry.get("hex_color") or entry.get("color", "")
|
||||||
if part_name and mat_name:
|
if part_name and hex_color:
|
||||||
raw_material_map[part_name] = mat_name
|
color_map[part_name] = hex_color
|
||||||
eng.dispose()
|
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)
|
step = _Path(step_path_str)
|
||||||
stl_path = step.parent / f"{step.stem}_low.stl"
|
if not step.exists():
|
||||||
if not stl_path.exists():
|
log_task_event(self.request.id, f"Failed: STEP file not found: {step}", "error")
|
||||||
log_task_event(self.request.id, f"Failed: STL cache not found: {stl_path}", "error")
|
raise RuntimeError(f"STEP file not found: {step}")
|
||||||
raise RuntimeError(f"STL cache not found: {stl_path}")
|
|
||||||
|
|
||||||
output_path = step.parent / f"{step.stem}_geometry.glb"
|
output_path = step.parent / f"{step.stem}_geometry.glb"
|
||||||
|
|
||||||
log_task_event(
|
log_task_event(
|
||||||
self.request.id,
|
self.request.id,
|
||||||
f"Starting GLB export: {len(material_map)} materials, "
|
f"Starting OCC GLB export: {len(color_map)} part colors",
|
||||||
f"{len(sharp_edge_midpoints)} sharp-edge hints, "
|
|
||||||
f"library={'yes' if asset_library_blend else 'no'}",
|
|
||||||
"info",
|
"info",
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- Blender path ---
|
# Run export_step_to_gltf.py as a subprocess so OCP imports don't pollute worker state
|
||||||
blender_bin = _os.environ.get("BLENDER_BIN", "blender")
|
|
||||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
||||||
script_path = scripts_dir / "export_gltf.py"
|
script_path = scripts_dir / "export_step_to_gltf.py"
|
||||||
blender_ok = False
|
|
||||||
|
|
||||||
if _Path(blender_bin).exists() and script_path.exists():
|
python_bin = _sys.executable
|
||||||
cmd = [
|
cmd = [
|
||||||
blender_bin, "--background", "--python", str(script_path), "--",
|
python_bin, str(script_path),
|
||||||
"--stl_path", str(stl_path),
|
"--step_path", str(step),
|
||||||
"--output_path", str(output_path),
|
"--output_path", str(output_path),
|
||||||
"--material_map", _json.dumps(material_map),
|
"--color_map", _json.dumps(color_map),
|
||||||
"--sharp_edges_json", _json.dumps(sharp_edge_midpoints),
|
|
||||||
]
|
]
|
||||||
if asset_library_blend and _Path(asset_library_blend).exists():
|
|
||||||
cmd += ["--asset_library_blend", asset_library_blend]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||||
if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
|
for line in result.stdout.splitlines():
|
||||||
blender_ok = True
|
logger.info("[occ-gltf] %s", line)
|
||||||
logger.info("generate_gltf_geometry_task: Blender export succeeded (%s KB)",
|
for line in result.stderr.splitlines():
|
||||||
output_path.stat().st_size // 1024)
|
logger.warning("[occ-gltf stderr] %s", line)
|
||||||
else:
|
|
||||||
logger.warning(
|
if result.returncode != 0 or not output_path.exists() or output_path.stat().st_size == 0:
|
||||||
"Blender GLB export failed (exit %d) — falling back to trimesh.\n"
|
raise RuntimeError(
|
||||||
"STDOUT: %s\nSTDERR: %s",
|
f"export_step_to_gltf.py failed (exit {result.returncode}).\n"
|
||||||
result.returncode, result.stdout[-1500:], result.stderr[-500:],
|
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:
|
except Exception as exc:
|
||||||
log_task_event(self.request.id, f"Failed: {exc}", "error")
|
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)
|
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) ---
|
# --- 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
|
import uuid as _uuid
|
||||||
from sqlalchemy import create_engine as _ce, delete as _del
|
from sqlalchemy import create_engine as _ce, delete as _del
|
||||||
from sqlalchemy.orm import Session as _Session
|
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):]
|
_key = _key[len(_prefix):]
|
||||||
asset = MediaAsset(
|
asset = MediaAsset(
|
||||||
cad_file_id=_uuid.UUID(cad_file_id),
|
cad_file_id=_uuid.UUID(cad_file_id),
|
||||||
|
product_id=_uuid.UUID(product_id) if product_id else None,
|
||||||
asset_type=MediaAssetType.gltf_geometry,
|
asset_type=MediaAssetType.gltf_geometry,
|
||||||
storage_key=_key,
|
storage_key=_key,
|
||||||
mime_type="model/gltf-binary",
|
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}
|
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")
|
@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):
|
def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
|
||||||
"""Regenerate thumbnail with per-part colours."""
|
"""Regenerate thumbnail with per-part colours."""
|
||||||
|
|||||||
+6
-39
@@ -67,30 +67,6 @@ export async function getCadObjects(cadFileId: string): Promise<CadObjects> {
|
|||||||
return res.data
|
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).
|
* 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).
|
* Returns the Celery task_id (or null if the worker is not available).
|
||||||
@@ -110,23 +86,14 @@ export interface GenerateGltfResponse {
|
|||||||
cad_file_id: string
|
cad_file_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Queue geometry GLB export directly from STEP via OCC (no Blender, no STL). */
|
||||||
* Queue GLB geometry export from existing STL cache (trimesh, no Blender).
|
|
||||||
* The STL low-quality cache must already exist.
|
|
||||||
*/
|
|
||||||
export async function generateGltfGeometry(cadFileId: string): Promise<GenerateGltfResponse> {
|
export async function generateGltfGeometry(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-geometry`)
|
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-geometry`)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const exportGltfColored = (id: string): Promise<void> =>
|
/** Queue production GLB export (Blender + PBR materials) from geometry GLB. */
|
||||||
api.get(`/cad/${id}/export-gltf-colored`, { responseType: 'blob' }).then(r => {
|
export async function generateGltfProduction(cadFileId: string): Promise<GenerateGltfResponse> {
|
||||||
const url = URL.createObjectURL(r.data)
|
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-production`)
|
||||||
const a = document.createElement('a')
|
return res.data
|
||||||
a.href = url
|
}
|
||||||
a.download = `${id}_colored.glb`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ export interface Product {
|
|||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
render_image_url: string | null
|
render_image_url: string | null
|
||||||
processing_status: string | null
|
processing_status: string | null
|
||||||
stl_cached: string[]
|
|
||||||
cad_parsed_objects: string[] | null
|
cad_parsed_objects: string[] | null
|
||||||
cad_mesh_attributes?: {
|
cad_mesh_attributes?: {
|
||||||
dimensions_mm?: { x: number; y: number; z: number }
|
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) {
|
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||||||
let geo = obj.geometry.clone()
|
let geo = obj.geometry.clone()
|
||||||
if (!geo.index) {
|
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).
|
// so computeVertexNormals() would give per-face normals (flat shading).
|
||||||
// mergeVertices() creates an indexed geometry with shared vertices first,
|
// mergeVertices() creates an indexed geometry with shared vertices first,
|
||||||
// so the subsequent normal computation averages across adjacent faces → smooth.
|
// so the subsequent normal computation averages across adjacent faces → smooth.
|
||||||
@@ -208,7 +208,7 @@ export default function InlineCadViewer({
|
|||||||
className="btn-secondary text-xs"
|
className="btn-secondary text-xs"
|
||||||
onClick={() => generateMut.mutate()}
|
onClick={() => generateMut.mutate()}
|
||||||
disabled={generateMut.isPending || generating}
|
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' : ''} />
|
<RefreshCw size={12} className={generating ? 'animate-spin' : ''} />
|
||||||
{generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'}
|
{generating ? 'Generating…' : generateMut.isPending ? 'Queuing…' : 'Load 3D Model'}
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export default function AdminPage() {
|
|||||||
blender_eevee_samples: number
|
blender_eevee_samples: number
|
||||||
threejs_render_size: number
|
threejs_render_size: number
|
||||||
thumbnail_format: string
|
thumbnail_format: string
|
||||||
stl_quality: string
|
|
||||||
blender_smooth_angle: number
|
blender_smooth_angle: number
|
||||||
cycles_device: string
|
cycles_device: string
|
||||||
blender_max_concurrent_renders: number
|
blender_max_concurrent_renders: number
|
||||||
@@ -159,14 +158,6 @@ export default function AdminPage() {
|
|||||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Import failed'),
|
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({
|
const reextractMetadataMut = useMutation({
|
||||||
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
|
mutationFn: () => api.post('/admin/settings/reextract-metadata'),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
@@ -397,29 +388,6 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Smooth by angle */}
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<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>
|
<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>
|
</button>
|
||||||
<p className="text-xs text-content-muted">Registers existing renders & CAD thumbnails in the Media Browser.</p>
|
<p className="text-xs text-content-muted">Registers existing renders & CAD thumbnails in the Media Browser.</p>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => reextractMetadataMut.mutate()}
|
onClick={() => reextractMetadataMut.mutate()}
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ export default function CadPreviewPage() {
|
|||||||
<Box size={48} className="text-gray-600" />
|
<Box size={48} className="text-gray-600" />
|
||||||
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
|
<p className="text-white text-lg font-semibold">No 3D model available yet</p>
|
||||||
<p className="text-gray-400 text-sm max-w-sm">
|
<p className="text-gray-400 text-sm max-w-sm">
|
||||||
Generate a GLB file from the STEP cache to enable the 3D viewer.
|
Generate a geometry GLB from the STEP file to enable the 3D viewer.
|
||||||
The STL cache must exist (process the STEP file first).
|
Process the STEP file first to make it available.
|
||||||
</p>
|
</p>
|
||||||
{generating ? (
|
{generating ? (
|
||||||
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
<div className="flex items-center gap-2 text-gray-300 text-sm">
|
||||||
@@ -116,7 +116,7 @@ export default function CadPreviewPage() {
|
|||||||
)}
|
)}
|
||||||
{generateMutation.isError && (
|
{generateMutation.isError && (
|
||||||
<p className="text-red-400 text-sm">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { listMaterials } from '../api/materials'
|
|||||||
import MaterialInput from '../components/shared/MaterialInput'
|
import MaterialInput from '../components/shared/MaterialInput'
|
||||||
import MaterialWizard from '../components/MaterialWizard'
|
import MaterialWizard from '../components/MaterialWizard'
|
||||||
import { useAuthStore } from '../store/auth'
|
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'
|
import InlineCadViewer from '../components/cad/InlineCadViewer'
|
||||||
|
|
||||||
function CadStatusBadge({ status }: { status: string | null }) {
|
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'),
|
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 [editPositionDraft, setEditPositionDraft] = useState<Partial<RenderPosition>>({})
|
||||||
|
|
||||||
const POSITION_PRESETS = [
|
const POSITION_PRESETS = [
|
||||||
@@ -526,160 +521,87 @@ export default function ProductDetailPage() {
|
|||||||
|
|
||||||
{product.cad_file_id ? (
|
{product.cad_file_id ? (
|
||||||
<div className="space-y-3">
|
<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
|
<InlineCadViewer
|
||||||
cadFileId={product.cad_file_id}
|
cadFileId={product.cad_file_id}
|
||||||
thumbnailUrl={product.render_image_url || product.thumbnail_url}
|
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 && (
|
{isPrivileged && (
|
||||||
<>
|
<>
|
||||||
|
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
|
||||||
<div {...getRootProps()} className="cursor-pointer">
|
<div {...getRootProps()} className="cursor-pointer">
|
||||||
<input {...getInputProps()} />
|
<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} />
|
<Upload size={12} />
|
||||||
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
|
{cadUploadMut.isPending ? 'Uploading…' : 'Re-upload STEP'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary text-xs"
|
className="btn-secondary text-xs w-full justify-start"
|
||||||
onClick={() => regenerateMut.mutate()}
|
onClick={() => regenerateMut.mutate()}
|
||||||
disabled={regenerateMut.isPending}
|
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} />
|
<RotateCcw size={12} />
|
||||||
{regenerateMut.isPending ? 'Queuing…' : 'Regenerate thumbnail'}
|
{regenerateMut.isPending ? 'Queuing…' : 'Regen thumbnail'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary text-xs"
|
className="btn-secondary text-xs w-full justify-start"
|
||||||
onClick={() => reprocessMut.mutate()}
|
onClick={() => reprocessMut.mutate()}
|
||||||
disabled={reprocessMut.isPending}
|
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} />
|
<RotateCcw size={12} />
|
||||||
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
|
{reprocessMut.isPending ? 'Queuing…' : 'Re-process STEP'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Mesh attributes */}
|
<div className="border-t border-border-light pt-2 mt-1 flex flex-col gap-2">
|
||||||
{(() => {
|
<button
|
||||||
// Prefer cad_mesh_attributes (reliably populated by API) over cad_file.mesh_attributes
|
className="btn-secondary text-xs w-full justify-start"
|
||||||
const mesh_attrs: Record<string, unknown> = (product.cad_mesh_attributes ?? product.cad_file?.mesh_attributes) as Record<string, unknown> ?? {}
|
onClick={() =>
|
||||||
if (Object.keys(mesh_attrs).length === 0) return null
|
generateGltfGeometry(product.cad_file_id!)
|
||||||
const dims = mesh_attrs.dimensions_mm as { x: number; y: number; z: number } | undefined
|
.then(() => toast.info('Geometry GLB export queued'))
|
||||||
const bbox = mesh_attrs.bbox as { x?: number; y?: number; z?: number } | undefined
|
.catch(() => toast.error('Failed to queue GLB export'))
|
||||||
return (
|
}
|
||||||
<div className="mt-3 p-3 rounded-md border border-border-default bg-surface-alt">
|
title="Export geometry GLB directly from STEP via OCC (no Blender)"
|
||||||
<p className="text-xs font-semibold text-content-muted mb-2 flex items-center gap-1">
|
>
|
||||||
<Ruler size={12} />
|
<Download size={12} />
|
||||||
Geometry
|
Generate Geometry GLB
|
||||||
</p>
|
</button>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
<button
|
||||||
{dims != null && (
|
className="btn-secondary text-xs w-full justify-start"
|
||||||
<>
|
onClick={() =>
|
||||||
<span className="text-content-muted">Dimensions</span>
|
generateGltfProduction(product.cad_file_id!)
|
||||||
<span>{dims.x.toFixed(1)} × {dims.y.toFixed(1)} × {dims.z.toFixed(1)} mm</span>
|
.then(() => toast.info('Production GLB export queued'))
|
||||||
</>
|
.catch(() => toast.error('Failed to queue production GLB export'))
|
||||||
)}
|
}
|
||||||
{dims == null && bbox != null && (
|
title="Export production GLB with PBR materials via Blender"
|
||||||
<>
|
>
|
||||||
<span className="text-content-muted">BBox</span>
|
<Download size={12} />
|
||||||
<span>
|
Generate Production GLB
|
||||||
{bbox.x?.toFixed(1)} × {bbox.y?.toFixed(1)} × {bbox.z?.toFixed(1)} mm
|
</button>
|
||||||
</span>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Material assignments */}
|
{/* Material assignments */}
|
||||||
{isPrivileged && (
|
{isPrivileged && (
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ else:
|
|||||||
|
|
||||||
if len(argv) < 4:
|
if len(argv) < 4:
|
||||||
print("Usage: blender --background --python blender_render.py -- "
|
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)
|
sys.exit(1)
|
||||||
|
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
stl_path = argv[0]
|
glb_path = argv[0]
|
||||||
output_path = argv[1]
|
output_path = argv[1]
|
||||||
width = int(argv[2])
|
width = int(argv[2])
|
||||||
height = int(argv[3])
|
height = int(argv[3])
|
||||||
@@ -173,23 +173,7 @@ def _assign_palette_material(part_obj, index):
|
|||||||
import re as _re
|
import re as _re
|
||||||
|
|
||||||
|
|
||||||
def _scale_mm_to_m(parts):
|
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
|
||||||
"""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)")
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_rotation(parts, rx, ry, rz):
|
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')
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
|
||||||
def _import_stl(stl_file):
|
def _import_glb(glb_file):
|
||||||
"""Import STL into Blender, using per-part STLs if available.
|
"""Import OCC-generated GLB into Blender.
|
||||||
|
|
||||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
OCC exports one mesh object per STEP part, already in metres.
|
||||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
Blender's native GLTF importer preserves part names.
|
||||||
- Fallback: imports combined STL and splits by loose geometry.
|
|
||||||
|
|
||||||
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.object.select_all(action='DESELECT')
|
||||||
bpy.ops.wm.stl_import(filepath=part_file)
|
bpy.ops.import_scene.gltf(filepath=glb_file)
|
||||||
imported = bpy.context.selected_objects
|
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||||
if imported:
|
|
||||||
obj = imported[0]
|
|
||||||
obj.name = part_name
|
|
||||||
if obj.data:
|
|
||||||
obj.data.name = part_name
|
|
||||||
parts.append(obj)
|
|
||||||
|
|
||||||
if parts:
|
|
||||||
print(f"[blender_render] imported {len(parts)} named parts from per-part STLs")
|
|
||||||
|
|
||||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
|
||||||
if not parts:
|
if not parts:
|
||||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
print(f"ERROR: No mesh objects imported from {glb_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}")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
bpy.context.view_layer.objects.active = obj
|
print(f"[blender_render] imported {len(parts)} part(s) from GLB: "
|
||||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
f"{[p.name for p in parts[:5]]}")
|
||||||
obj.location = (0.0, 0.0, 0.0)
|
|
||||||
|
|
||||||
bpy.ops.object.mode_set(mode='EDIT')
|
# Centre combined bbox at world origin
|
||||||
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) ────────────────
|
|
||||||
all_corners = []
|
all_corners = []
|
||||||
for p in parts:
|
for p in parts:
|
||||||
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
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
|
# Find or create target collection
|
||||||
target_col = _ensure_collection(target_collection)
|
target_col = _ensure_collection(target_collection)
|
||||||
|
|
||||||
# Import and split STL
|
# Import OCC GLB (already in metres, one object per STEP part)
|
||||||
parts = _import_stl(stl_path)
|
parts = _import_glb(glb_path)
|
||||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
|
||||||
_scale_mm_to_m(parts)
|
|
||||||
# Apply render position rotation (before camera/bbox calculations)
|
# Apply render position rotation (before camera/bbox calculations)
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
|
|
||||||
@@ -538,9 +472,8 @@ else:
|
|||||||
# ── MODE A: Factory settings (original behavior) ─────────────────────────
|
# ── MODE A: Factory settings (original behavior) ─────────────────────────
|
||||||
needs_auto_camera = True
|
needs_auto_camera = True
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
parts = _import_stl(stl_path)
|
# Import OCC GLB (already in metres, one object per STEP part)
|
||||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
parts = _import_glb(glb_path)
|
||||||
_scale_mm_to_m(parts)
|
|
||||||
# Apply render position rotation (before camera/bbox calculations)
|
# Apply render position rotation (before camera/bbox calculations)
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_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:
|
Usage:
|
||||||
blender --background --python export_blend.py -- \\
|
blender --background --python export_blend.py -- \\
|
||||||
--stl_path /path/to/file.stl \\
|
--glb_path /path/to/geometry.glb \\
|
||||||
--output_path /path/to/output.blend \\
|
--output_path /path/to/output.blend \\
|
||||||
[--asset_library_blend /path/to/library.blend] \\
|
[--asset_library_blend /path/to/library.blend] \\
|
||||||
[--material_map '{"SrcMat": "LibMat"}']
|
[--material_map '{"SrcMat": "LibMat"}']
|
||||||
|
|
||||||
The script:
|
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.
|
2. Optionally applies asset library materials from a .blend.
|
||||||
3. Packs all external data.
|
3. Packs all external data.
|
||||||
4. Saves a copy as the output .blend.
|
4. Saves a copy as the output .blend.
|
||||||
@@ -28,7 +28,8 @@ def parse_args() -> argparse.Namespace:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
rest = argv[argv.index("--") + 1:]
|
rest = argv[argv.index("--") + 1:]
|
||||||
parser = argparse.ArgumentParser()
|
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("--output_path", required=True)
|
||||||
parser.add_argument("--asset_library_blend", default=None)
|
parser.add_argument("--asset_library_blend", default=None)
|
||||||
parser.add_argument("--material_map", default="{}")
|
parser.add_argument("--material_map", default="{}")
|
||||||
@@ -44,14 +45,8 @@ def main() -> None:
|
|||||||
# Clean scene
|
# Clean scene
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
|
||||||
# Import STL
|
# Import geometry GLB (metres, Y-up — no rescaling needed)
|
||||||
bpy.ops.import_mesh.stl(filepath=args.stl_path)
|
bpy.ops.import_scene.gltf(filepath=args.glb_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)
|
|
||||||
|
|
||||||
# Apply asset library materials if provided
|
# Apply asset library materials if provided
|
||||||
if args.asset_library_blend and material_map:
|
if args.asset_library_blend and material_map:
|
||||||
|
|||||||
@@ -27,78 +27,30 @@ def parse_args() -> argparse.Namespace:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
rest = argv[argv.index("--") + 1:]
|
rest = argv[argv.index("--") + 1:]
|
||||||
parser = argparse.ArgumentParser()
|
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("--output_path", required=True)
|
||||||
parser.add_argument("--asset_library_blend", default=None)
|
parser.add_argument("--asset_library_blend", default=None)
|
||||||
parser.add_argument("--material_map", default="{}")
|
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)
|
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:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
material_map: dict = json.loads(args.material_map)
|
material_map: dict = json.loads(args.material_map)
|
||||||
sharp_edge_midpoints: list = json.loads(args.sharp_edges_json)
|
|
||||||
|
|
||||||
import bpy # type: ignore[import]
|
import bpy # type: ignore[import]
|
||||||
|
import math as _math
|
||||||
|
|
||||||
# Clean scene
|
# Clean scene
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
|
||||||
# Import STL (bpy.ops.wm.stl_import is the Blender 4.0+ API)
|
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
|
||||||
bpy.ops.wm.stl_import(filepath=args.stl_path)
|
bpy.ops.import_scene.gltf(filepath=args.glb_path)
|
||||||
|
print(f"Imported geometry GLB: {args.glb_path} "
|
||||||
# Scale mm → m
|
f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)")
|
||||||
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)
|
|
||||||
|
|
||||||
# Apply smooth shading with 30° angle threshold (Blender 4.1+ API)
|
# Apply smooth shading with 30° angle threshold (Blender 4.1+ API)
|
||||||
import math as _math
|
|
||||||
for obj in bpy.data.objects:
|
for obj in bpy.data.objects:
|
||||||
if obj.type == "MESH":
|
if obj.type == "MESH":
|
||||||
bpy.context.view_layer.objects.active = obj
|
bpy.context.view_layer.objects.active = obj
|
||||||
@@ -108,37 +60,30 @@ def main() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.
|
# Apply asset library materials if provided.
|
||||||
# link=False (append) is required for GLB export: the GLTF exporter can only
|
# link=False (append) is required: the GLTF exporter can only traverse
|
||||||
# traverse local (appended) Principled BSDF node trees to extract PBR values.
|
# local (appended) Principled BSDF node trees to extract PBR values.
|
||||||
# Linked materials are external references whose node data is not accessible.
|
|
||||||
if args.asset_library_blend and material_map:
|
if args.asset_library_blend and material_map:
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
from asset_library import apply_asset_library_materials
|
from asset_library import apply_asset_library_materials
|
||||||
apply_asset_library_materials(args.asset_library_blend, material_map, link=False)
|
apply_asset_library_materials(args.asset_library_blend, material_map, link=False)
|
||||||
|
|
||||||
# Export GLB with full PBR material data
|
# Export production GLB with full PBR material data
|
||||||
# Note: export_colors was removed in Blender 4.x — do not pass it.
|
|
||||||
try:
|
try:
|
||||||
bpy.ops.export_scene.gltf(
|
bpy.ops.export_scene.gltf(
|
||||||
filepath=args.output_path,
|
filepath=args.output_path,
|
||||||
export_format="GLB",
|
export_format="GLB",
|
||||||
export_apply=True,
|
export_apply=True,
|
||||||
use_selection=False,
|
use_selection=False,
|
||||||
export_materials="EXPORT", # export all materials (Principled BSDF → glTF PBR)
|
export_materials="EXPORT",
|
||||||
export_image_format="AUTO", # embed textures (base color, normal, roughness maps)
|
export_image_format="AUTO",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"GLB export failed: {exc}", file=sys.stderr)
|
print(f"GLB export failed: {exc}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print(f"GLB exported to {args.output_path}")
|
print(f"Production GLB exported to {args.output_path}")
|
||||||
|
|
||||||
|
|
||||||
try:
|
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
|
import re as _re
|
||||||
|
|
||||||
|
|
||||||
def _scale_mm_to_m(parts):
|
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
|
||||||
"""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)")
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_rotation(parts, rx, ry, rz):
|
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')
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
|
||||||
def _import_stl(stl_file):
|
def _import_glb(glb_file):
|
||||||
"""Import STL into Blender, using per-part STLs if available.
|
"""Import OCC-generated GLB into Blender.
|
||||||
|
|
||||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
OCC exports one mesh object per STEP part, already in metres.
|
||||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
Returns list of Blender mesh objects, centred at world origin.
|
||||||
- Fallback: imports combined STL and splits by loose geometry.
|
|
||||||
|
|
||||||
Returns list of Blender mesh objects, centred at 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.object.select_all(action='DESELECT')
|
||||||
bpy.ops.wm.stl_import(filepath=part_file)
|
bpy.ops.import_scene.gltf(filepath=glb_file)
|
||||||
imported = bpy.context.selected_objects
|
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||||
if imported:
|
|
||||||
obj = imported[0]
|
|
||||||
obj.name = part_name
|
|
||||||
if obj.data:
|
|
||||||
obj.data.name = part_name
|
|
||||||
parts.append(obj)
|
|
||||||
|
|
||||||
if parts:
|
|
||||||
print(f"[still_render] imported {len(parts)} named parts from per-part STLs")
|
|
||||||
|
|
||||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
|
||||||
if not parts:
|
if not parts:
|
||||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
print(f"ERROR: No mesh objects imported from {glb_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}")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
bpy.context.view_layer.objects.active = obj
|
print(f"[still_render] imported {len(parts)} part(s) from GLB: "
|
||||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
f"{[p.name for p in parts[:5]]}")
|
||||||
obj.location = (0.0, 0.0, 0.0)
|
|
||||||
|
|
||||||
bpy.ops.object.mode_set(mode='EDIT')
|
# Centre combined bbox at world origin
|
||||||
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) ────────────────
|
|
||||||
all_corners = []
|
all_corners = []
|
||||||
for p in parts:
|
for p in parts:
|
||||||
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
||||||
@@ -376,7 +310,7 @@ def main():
|
|||||||
argv = sys.argv
|
argv = sys.argv
|
||||||
args = argv[argv.index("--") + 1:]
|
args = argv[argv.index("--") + 1:]
|
||||||
|
|
||||||
stl_path = args[0]
|
glb_path = args[0]
|
||||||
output_path = args[1]
|
output_path = args[1]
|
||||||
width = int(args[2])
|
width = int(args[2])
|
||||||
height = int(args[3])
|
height = int(args[3])
|
||||||
@@ -460,10 +394,8 @@ def main():
|
|||||||
# Find or create target collection
|
# Find or create target collection
|
||||||
target_col = _ensure_collection(target_collection)
|
target_col = _ensure_collection(target_collection)
|
||||||
|
|
||||||
# Import and split STL
|
# Import OCC GLB (already in metres, one object per STEP part)
|
||||||
parts = _import_stl(stl_path)
|
parts = _import_glb(glb_path)
|
||||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
|
||||||
_scale_mm_to_m(parts)
|
|
||||||
# Apply render position rotation (before camera/bbox calculations)
|
# Apply render position rotation (before camera/bbox calculations)
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
# Apply OCC topology-based shading overrides
|
# Apply OCC topology-based shading overrides
|
||||||
@@ -562,9 +494,7 @@ def main():
|
|||||||
needs_auto_camera = True
|
needs_auto_camera = True
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
|
||||||
parts = _import_stl(stl_path)
|
parts = _import_glb(glb_path)
|
||||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
|
||||||
_scale_mm_to_m(parts)
|
|
||||||
# Apply render position rotation (before camera/bbox calculations)
|
# Apply render position rotation (before camera/bbox calculations)
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
# Apply OCC topology-based shading overrides
|
# 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))
|
draw.rectangle([0, 0, W - 1, bar_h - 1], fill=(0, 137, 61, 255))
|
||||||
|
|
||||||
# Model name strip at bottom
|
# 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)
|
label_h = max(20, H // 20)
|
||||||
img.alpha_composite(
|
img.alpha_composite(
|
||||||
Image.new("RGBA", (W, label_h), (30, 30, 30, 180)),
|
Image.new("RGBA", (W, label_h), (30, 30, 30, 180)),
|
||||||
|
|||||||
@@ -138,23 +138,7 @@ def _set_fcurves_linear(action):
|
|||||||
kp.interpolation = 'LINEAR'
|
kp.interpolation = 'LINEAR'
|
||||||
|
|
||||||
|
|
||||||
def _scale_mm_to_m(parts):
|
# _scale_mm_to_m removed: OCC GLB export produces coordinates in metres already.
|
||||||
"""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)")
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_mesh_attributes(objects: list, mesh_attributes: dict) -> None:
|
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
|
obj.data.auto_smooth_angle = threshold_rad
|
||||||
|
|
||||||
|
|
||||||
def _import_stl(stl_file):
|
def _import_glb(glb_file):
|
||||||
"""Import STL into Blender, using per-part STLs if available.
|
"""Import OCC-generated GLB into Blender.
|
||||||
|
|
||||||
Checks for {stl_stem}_parts/manifest.json next to the STL file.
|
OCC exports one mesh object per STEP part, already in metres.
|
||||||
- Per-part mode: imports each part STL, names Blender object after STEP part name.
|
Returns list of Blender mesh objects, centred at world origin.
|
||||||
- Fallback: imports combined STL and splits by loose geometry.
|
|
||||||
|
|
||||||
Returns list of Blender mesh objects, centred at 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.object.select_all(action='DESELECT')
|
||||||
bpy.ops.wm.stl_import(filepath=part_file)
|
bpy.ops.import_scene.gltf(filepath=glb_file)
|
||||||
imported = bpy.context.selected_objects
|
parts = [o for o in bpy.context.selected_objects if o.type == 'MESH']
|
||||||
if imported:
|
|
||||||
obj = imported[0]
|
|
||||||
obj.name = part_name
|
|
||||||
if obj.data:
|
|
||||||
obj.data.name = part_name
|
|
||||||
parts.append(obj)
|
|
||||||
|
|
||||||
if parts:
|
|
||||||
print(f"[turntable_render] imported {len(parts)} named parts from per-part STLs")
|
|
||||||
|
|
||||||
# ── Fallback: combined STL + separate by loose ───────────────────────
|
|
||||||
if not parts:
|
if not parts:
|
||||||
bpy.ops.wm.stl_import(filepath=stl_file)
|
print(f"ERROR: No mesh objects imported from {glb_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}")
|
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
bpy.context.view_layer.objects.active = obj
|
print(f"[turntable_render] imported {len(parts)} part(s) from GLB: "
|
||||||
bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS')
|
f"{[p.name for p in parts[:5]]}")
|
||||||
obj.location = (0.0, 0.0, 0.0)
|
|
||||||
|
|
||||||
bpy.ops.object.mode_set(mode='EDIT')
|
# Centre combined bbox at world origin
|
||||||
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) ────────────────
|
|
||||||
all_corners = []
|
all_corners = []
|
||||||
for p in parts:
|
for p in parts:
|
||||||
all_corners.extend(p.matrix_world @ Vector(c) for c in p.bound_box)
|
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
|
# Everything after "--" is our args
|
||||||
args = argv[argv.index("--") + 1:]
|
args = argv[argv.index("--") + 1:]
|
||||||
|
|
||||||
stl_path = args[0]
|
glb_path = args[0]
|
||||||
frames_dir = args[1]
|
frames_dir = args[1]
|
||||||
frame_count = int(args[2])
|
frame_count = int(args[2])
|
||||||
degrees = int(args[3])
|
degrees = int(args[3])
|
||||||
@@ -427,10 +361,8 @@ def main():
|
|||||||
# Find or create target collection
|
# Find or create target collection
|
||||||
target_col = _ensure_collection(target_collection)
|
target_col = _ensure_collection(target_collection)
|
||||||
|
|
||||||
# Import and split STL
|
# Import OCC GLB (already in metres, one object per STEP part)
|
||||||
parts = _import_stl(stl_path)
|
parts = _import_glb(glb_path)
|
||||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
|
||||||
_scale_mm_to_m(parts)
|
|
||||||
# Apply render position rotation before material/camera setup
|
# Apply render position rotation before material/camera setup
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
# Apply OCC topology-based shading overrides
|
# Apply OCC topology-based shading overrides
|
||||||
@@ -508,9 +440,7 @@ def main():
|
|||||||
needs_auto_camera = True
|
needs_auto_camera = True
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
|
||||||
parts = _import_stl(stl_path)
|
parts = _import_glb(glb_path)
|
||||||
# Scale mm→m: STEP coords are mm, Blender default unit is metres
|
|
||||||
_scale_mm_to_m(parts)
|
|
||||||
# Apply render position rotation before material/camera setup
|
# Apply render position rotation before material/camera setup
|
||||||
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
_apply_rotation(parts, rotation_x, rotation_y, rotation_z)
|
||||||
# Apply OCC topology-based shading overrides
|
# Apply OCC topology-based shading overrides
|
||||||
|
|||||||
Reference in New Issue
Block a user