feat: per-position camera settings, material alias dialog, product delete, media browser links

- Per-render-position focal_length_mm/sensor_width_mm (DB → pipeline → Blender)
- FOV-based camera distance with min clamp fix for wide-angle lenses
- Unmapped materials blocking dialog on "Dispatch Renders" with batch alias creation
- Material check endpoint (GET /orders/{id}/check-materials)
- Batch alias endpoint (POST /materials/batch-aliases)
- Quick-map "No alias" badges on Materials page
- Full product hard-delete with storage cleanup (MinIO + disk files + orphaned CadFile)
- Delete button on ProductDetail page with confirmation
- Clickable product names in Media Browser (links to product page)
- Single-line render dispatch/retry (POST /orders/{id}/lines/{id}/dispatch-render)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 12:16:37 +01:00
parent 0020376702
commit b583b0d7a2
48 changed files with 1827 additions and 376 deletions
+15 -4
View File
@@ -7,6 +7,12 @@
## Learnings
### 2026-03-14 | Blender | OBJ-Rotation Turntable: Reparenting von USD-Parts verschiebt Geometrie
USD-importierte Parts haben existierende Eltern-Objekte (Xform-Nodes). `part.parent = pivot; part.matrix_parent_inverse = pivot.matrix_world.inverted()` verliert den Beitrag des alten Parents zur Welt-Position → Parts verschieben sich ~14m. Fix: World-Matrix vor Reparenting sichern, dann via `part.matrix_local = pivot.inverted() @ saved_world` wiederherstellen. Zusätzlich `bpy.context.view_layer.update()` vor dem Parenting aufrufen, damit `pivot.matrix_world` aktuell ist.
### 2026-03-13 | Blender/USD | USD-Import erzeugt leere Material-Stubs → schwarze Renders
Blender's `bpy.ops.wm.usd_import()` erstellt leere Material-Stubs (use_nodes=True, nodes=0) aus USD MaterialBinding-Referenzen. Wenn `apply_material_library_direct` dann die echten Materialien aus der .blend-Bibliothek laden will, findet es die Stubs bereits in `bpy.data.materials` und nutzt sie direkt — ohne die echten Shader-Node-Trees zu laden. Leere Materialien rendern in Cycles als pures Schwarz (RGB max=1). Fix: `_find_material_with_nodes()` Hilfsfunktion, die `.NNN`-Suffix-Kollisionen auflöst und nur Materialien mit echten Nodes zurückgibt.
### 2026-03-12 | Caching | Composite Cache Keys für Tessellierung
Hash-basiertes Caching in Celery Tasks muss alle relevanten Parameter einschließen, nicht nur den Datei-Hash. Bei `generate_gltf_geometry_task` und `generate_usd_master_task` wurde der Cache-Key auf `{hash}:{linear}:{angular}:{engine}` erweitert. Außerdem: immer Disk-Existenz des gecachten Assets prüfen (`storage_key.exists()`) bevor ein Cache-Hit zurückgegeben wird — der Asset-Record kann existieren, die Datei aber nicht.
@@ -466,11 +472,7 @@ for obj in mesh_objects:
---
## Offene Fragen
- [ ] Azure AI Credentials für Phase 4 (Bildvalidierung) noch nicht konfiguriert
- [ ] pythonOCC verfügbar im render-worker (via cadquery dependency)? Deployment-Test ausstehend
- [ ] @xyflow/react noch nicht installiert — npm install nötig nach nächstem `docker compose up --build frontend`
- [ ] Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab
- [ ] Turntable-Animation: bg_color via FFmpeg-Overlay — Qualität bei Transparenz-Edges prüfen
### 2026-03-11 | OCP/Python | id(solid.TShape()) ist nicht stabil
In OCP (pybind11-basiert) gibt jeder Aufruf von `solid.TShape()` ein neues Python-Wrapper-Objekt zurück, das dieselbe C++ TShape-Instanz wrapet. `id()` gibt daher jedes Mal einen anderen Wert → Deduplizierung per `id()` schlägt immer fehl. **Lösung:** `solid.IsSame(other_solid)` verwenden (vergleicht TShape-Zeiger intern, liefert True für gleiche TShape mit unterschiedlicher Location/Orientation).
@@ -492,3 +494,12 @@ GMSH 4.15.1 in render-worker installiert. `tessellation_engine=gmsh` ist der akt
### 2026-03-12 | Debugging | Stale GLB-Cache maskiert Code-Fixes
Bug "Wälzkörper an falscher Position" war in Code durch commit 638b93b (IsSame-Fix) bereits behoben. Aber gecachtes Produktions-GLB (vor dem Fix generiert) zeigte weiterhin falsche Positionen im Viewer. Lösung: Geometry-GLB manuell neu generieren oder `step_file_hash = NULL` in DB um Cache-Invalidierung zu erzwingen. Nach Code-Fixes an Tessellierung/Export IMMER alle betroffenen GLB-Caches invalidieren.
### 2026-03-13 | OCC/GLB | BRepBuilderAPI_Transform zerstört Tessellierung + RWGltf_CafWriter MergeFaces
**Problem**: GLB-Dateien hatten nur ~164 Vertices pro Bauteil statt ~7000. Zwei Ursachen:
1. `BRepBuilderAPI_Transform(shape, trsf, copy=True)` zerstört **alle** `Poly_Triangulation`-Daten. Die mm→m-Skalierung vor dem Export löschte die BRepMesh-Tessellierung.
2. `RWGltf_CafWriter` mit `MergeFaces=False` (Default) findet keine per-Face-Tessellierung aus der XCAF-Komponentenhierarchie und produziert degenerierte Meshes.
**Lösung**:
- `BRepBuilderAPI_Transform` komplett entfernt — `RWGltf_CafWriter` konvertiert intern mm→m und Z-up→Y-up.
- `writer.SetMergeFaces(True)` hinzugefügt — composited Face-Triangulationen zu korrekten Shape-Buffern (1.212 → 46.573 Vertices).
**Merke**: OCC `TopLoc_Location` kann keine Skalierung (wirft `Standard_DomainError`). Für Skalierung entweder den Writer intern konvertieren lassen oder GLB post-process.
+2 -1
View File
@@ -16,8 +16,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy docker CLI for worker scaling
# Copy docker CLI + compose plugin for worker scaling
COPY --from=docker-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker-cli /usr/local/libexec/docker/cli-plugins/docker-compose /usr/local/libexec/docker/cli-plugins/docker-compose
# Install Python dependencies (dev + cad extras: pytest, trimesh, pygltflib)
COPY pyproject.toml .
@@ -0,0 +1,36 @@
"""add focal_length_mm and sensor_width_mm to render positions
Revision ID: 4c15abf3cf40
Revises: 6ebfe2737531
Create Date: 2026-03-14 06:31:13.141830
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c15abf3cf40'
down_revision: Union[str, None] = '6ebfe2737531'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('global_render_positions', sa.Column('focal_length_mm', sa.Float(), nullable=True))
op.add_column('global_render_positions', sa.Column('sensor_width_mm', sa.Float(), nullable=True))
op.add_column('product_render_positions', sa.Column('focal_length_mm', sa.Float(), nullable=True))
op.add_column('product_render_positions', sa.Column('sensor_width_mm', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('product_render_positions', 'sensor_width_mm')
op.drop_column('product_render_positions', 'focal_length_mm')
op.drop_column('global_render_positions', 'sensor_width_mm')
op.drop_column('global_render_positions', 'focal_length_mm')
# ### end Alembic commands ###
+57
View File
@@ -825,3 +825,60 @@ async def import_existing_media_assets(
await db.commit()
return {"created": created, "skipped": skipped}
@router.delete("/settings/purge-render-media", status_code=status.HTTP_200_OK)
async def purge_render_media(
admin: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db),
):
"""Delete all still and turntable MediaAsset records and their backing files.
This removes rendered images and animations but leaves thumbnails, GLBs,
STLs, and USD masters intact.
"""
import logging
from pathlib import Path
from app.config import settings
from app.core.storage import get_storage
from app.domains.media.models import MediaAsset, MediaAssetType
logger = logging.getLogger(__name__)
storage = get_storage()
result = await db.execute(
select(MediaAsset).where(
MediaAsset.asset_type.in_([MediaAssetType.still, MediaAssetType.turntable])
)
)
assets = result.scalars().all()
deleted_db = 0
deleted_files = 0
freed_bytes = 0
for asset in assets:
# Delete backing file
key = asset.storage_key
try:
candidate = Path(key) if Path(key).is_absolute() else Path(settings.upload_dir) / key
if candidate.exists():
freed_bytes += candidate.stat().st_size
candidate.unlink()
deleted_files += 1
elif hasattr(storage, 'delete'):
storage.delete(key)
deleted_files += 1
except Exception as exc:
logger.warning("Could not delete file for asset %s (%s): %s", asset.id, key, exc)
await db.delete(asset)
deleted_db += 1
await db.commit()
return {
"deleted_records": deleted_db,
"deleted_files": deleted_files,
"freed_mb": round(freed_bytes / 1024 / 1024, 1),
"message": f"Purged {deleted_db} still/turntable asset(s), freed {round(freed_bytes / 1024 / 1024, 1)} MB",
}
+1 -1
View File
@@ -313,7 +313,7 @@ async def regenerate_thumbnail(
db: AsyncSession = Depends(get_db),
):
"""Queue a Celery task to reprocess the STEP file and regenerate its thumbnail."""
if user.role.value != "admin":
if user.role.value not in ("admin", "global_admin", "tenant_admin"):
raise HTTPException(
status_code=403,
detail="Only admins can trigger thumbnail regeneration",
+56
View File
@@ -174,6 +174,62 @@ async def seed_aliases(
return {"inserted": inserted, "total": total}
class BatchAliasMapping(BaseModel):
alias: str
material_id: uuid.UUID
class BatchAliasCreate(BaseModel):
mappings: list[BatchAliasMapping]
@router.post("/batch-aliases")
async def batch_create_aliases(
body: BatchAliasCreate,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Create multiple material aliases in one request.
Skips aliases that already exist (case-insensitive). Validates that
each material_id exists.
"""
created = 0
skipped = 0
for mapping in body.mappings:
alias_str = mapping.alias.strip()
if not alias_str:
skipped += 1
continue
# Verify material exists
mat_result = await db.execute(
select(Material).where(Material.id == mapping.material_id)
)
if not mat_result.scalar_one_or_none():
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"Material {mapping.material_id} not found",
)
# Check if alias already exists (case-insensitive)
existing = await db.execute(
select(MaterialAlias).where(
func.lower(MaterialAlias.alias) == alias_str.lower()
)
)
if existing.scalar_one_or_none():
skipped += 1
continue
db.add(MaterialAlias(material_id=mapping.material_id, alias=alias_str))
created += 1
await db.commit()
return {"created": created, "skipped": skipped}
@router.delete("/aliases/{alias_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_alias(
alias_id: uuid.UUID,
+96
View File
@@ -865,6 +865,50 @@ async def add_order_line(
return _build_line_out(line_loaded)
@router.get("/{order_id}/check-materials")
async def check_materials(
order_id: uuid.UUID,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Check if all materials in this order's products are mapped to library materials."""
from app.domains.materials.service import find_unmapped_materials
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
lines_result = await db.execute(
select(OrderLine)
.options(selectinload(OrderLine.product))
.where(OrderLine.order_id == order_id)
)
lines = lines_result.scalars().all()
# Collect all unique material names from all products
all_material_names: list[str] = []
seen: set[str] = set()
for line in lines:
if not line.product or not line.product.cad_part_materials:
continue
for entry in line.product.cad_part_materials:
mat_name = entry.get("material", "")
if mat_name and mat_name.lower() not in seen:
seen.add(mat_name.lower())
all_material_names.append(mat_name)
unmapped = await find_unmapped_materials(all_material_names, db)
total = len(all_material_names)
mapped = total - len(unmapped)
return {
"unmapped": unmapped,
"total_materials": total,
"mapped_count": mapped,
}
@router.post("/{order_id}/dispatch-renders")
async def dispatch_renders(
order_id: uuid.UUID,
@@ -1000,6 +1044,58 @@ async def cancel_line_render(
}
@router.post("/{order_id}/lines/{line_id}/dispatch-render")
async def dispatch_single_line_render(
order_id: uuid.UUID,
line_id: uuid.UUID,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Dispatch (or retry) a render for a single order line (admin/PM only)."""
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
line_result = await db.execute(
select(OrderLine).where(OrderLine.id == line_id, OrderLine.order_id == order.id)
)
line = line_result.scalar_one_or_none()
if not line:
raise HTTPException(404, detail="Order line not found")
if line.render_status not in ("pending", "failed", "cancelled"):
raise HTTPException(400, detail=f"Cannot dispatch line in {line.render_status} status")
# Reset to pending
from sqlalchemy import update as sql_update
await db.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(render_status="pending", render_completed_at=None, render_log=None)
)
# Auto-advance order to processing if needed
if order.status in (OrderStatus.submitted, OrderStatus.completed):
now = datetime.utcnow()
order.status = OrderStatus.processing
order.processing_started_at = now
order.completed_at = None
order.updated_at = now
await db.commit()
from app.domains.rendering.dispatch_service import dispatch_render_with_workflow
try:
dispatch_render_with_workflow(str(line.id))
except Exception as exc:
logger.warning("dispatch_render_with_workflow failed for %s: %s", line.id, exc)
from app.tasks.step_tasks import dispatch_order_line_render
dispatch_order_line_render.delay(str(line.id))
return {"dispatched": True, "line_id": str(line.id)}
class RejectLineBody(BaseModel):
reason: str = ""
+63 -2
View File
@@ -270,12 +270,73 @@ async def delete_product(
raise HTTPException(404, detail="Product not found")
if hard:
from sqlalchemy import delete as sql_delete
# Delete order_lines referencing this product
from app.domains.media.models import MediaAsset
from app.core.storage import get_storage
# 1. Collect storage keys from MediaAssets before cascade deletes them
media_result = await db.execute(
select(MediaAsset.storage_key).where(MediaAsset.product_id == product_id)
)
storage_keys = [row[0] for row in media_result.all() if row[0]]
# 2. Collect render result paths from order lines
ol_result = await db.execute(
select(OrderLine.result_path).where(
OrderLine.product_id == product_id,
OrderLine.result_path.isnot(None),
)
)
result_paths = [row[0] for row in ol_result.all() if row[0]]
# 3. Check if CadFile is used by other products
cad_file_id = product.cad_file_id
orphan_cad = False
if cad_file_id:
other_count = await db.execute(
select(func.count(Product.id)).where(
Product.cad_file_id == cad_file_id,
Product.id != product_id,
)
)
orphan_cad = (other_count.scalar() or 0) == 0
# 4. Delete order_lines referencing this product
await db.execute(sql_delete(OrderLine).where(OrderLine.product_id == product_id))
# 5. Delete orphaned CadFile if no other products reference it
if orphan_cad and cad_file_id:
from app.models.cad_file import CadFile
# Collect CadFile media assets too
cad_media_result = await db.execute(
select(MediaAsset.storage_key).where(MediaAsset.cad_file_id == cad_file_id)
)
storage_keys.extend(row[0] for row in cad_media_result.all() if row[0])
product.cad_file_id = None
await db.flush()
await db.execute(sql_delete(CadFile).where(CadFile.id == cad_file_id))
# 6. Delete product (cascades MediaAsset + ProductRenderPosition)
await db.delete(product)
await db.commit()
# 7. Clean up storage files (best-effort, after commit)
storage = get_storage()
for key in storage_keys:
try:
storage.delete(key)
except Exception:
pass
# Clean up render result files on disk
import os
for path in result_paths:
try:
if os.path.isfile(path):
os.unlink(path)
except Exception:
pass
else:
product.is_active = False
await db.commit()
await db.commit()
@router.post("/{product_id}/cad", status_code=status.HTTP_201_CREATED)
+56 -16
View File
@@ -116,9 +116,11 @@ async def list_render_templates(
@router.post("/render-templates", response_model=RenderTemplateOut, status_code=status.HTTP_201_CREATED)
async def create_render_template(
name: str = Form(...),
file: UploadFile = File(...),
file: UploadFile | None = File(None),
clone_blend_from: str | None = Form(None),
category_key: str | None = Form(None),
output_type_id: str | None = Form(None),
output_type_ids: str | None = Form(None),
target_collection: str = Form("Product"),
material_replace_enabled: bool = Form(False),
lighting_only: bool = Form(False),
@@ -127,30 +129,54 @@ async def create_render_template(
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
if not file.filename or not file.filename.endswith(".blend"):
raise HTTPException(400, detail="File must be a .blend file")
# Normalise empty strings from form data to None
if category_key == "" or category_key == "null":
category_key = None
if output_type_id == "" or output_type_id == "null":
output_type_id = None
if clone_blend_from == "" or clone_blend_from == "null":
clone_blend_from = None
template_id = uuid.uuid4()
blend_path = _blend_dir() / f"{template_id}.blend"
with open(blend_path, "wb") as f:
shutil.copyfileobj(file.file, f)
if file and file.filename:
if not file.filename.endswith(".blend"):
raise HTTPException(400, detail="File must be a .blend file")
with open(blend_path, "wb") as f:
shutil.copyfileobj(file.file, f)
original_filename = file.filename
final_blend_path = str(blend_path)
elif clone_blend_from:
# Share the same .blend file (no copy — just reference the same path)
source = await db.execute(
select(RenderTemplate).where(RenderTemplate.id == uuid.UUID(clone_blend_from))
)
source_tmpl = source.unique().scalar_one_or_none()
if not source_tmpl:
raise HTTPException(404, detail="Source template not found")
source_path = Path(source_tmpl.blend_file_path)
if not source_path.exists():
raise HTTPException(404, detail="Source .blend file not found on disk")
final_blend_path = source_tmpl.blend_file_path
original_filename = source_tmpl.original_filename
else:
raise HTTPException(400, detail="Provide either a .blend file or clone_blend_from template ID")
ot_uuid = uuid.UUID(output_type_id) if output_type_id else None
# Parse M2M output_type_ids (comma-separated string from FormData)
m2m_ot_ids: list[str] = []
if output_type_ids and output_type_ids.strip():
m2m_ot_ids = [s.strip() for s in output_type_ids.split(",") if s.strip()]
tmpl = RenderTemplate(
id=template_id,
name=name,
category_key=category_key,
output_type_id=ot_uuid,
blend_file_path=str(blend_path),
original_filename=file.filename,
blend_file_path=final_blend_path,
original_filename=original_filename,
target_collection=target_collection,
material_replace_enabled=material_replace_enabled,
lighting_only=lighting_only,
@@ -160,12 +186,13 @@ async def create_render_template(
db.add(tmpl)
await db.flush()
# Sync M2M from initial output_type_id
if ot_uuid:
from app.domains.rendering.models import render_template_output_types
# Sync M2M output types
from app.domains.rendering.models import render_template_output_types
ot_ids_to_link = m2m_ot_ids if m2m_ot_ids else ([str(ot_uuid)] if ot_uuid else [])
for ot_id_str in ot_ids_to_link:
await db.execute(
render_template_output_types.insert().values(
template_id=template_id, output_type_id=ot_uuid,
template_id=template_id, output_type_id=uuid.UUID(ot_id_str),
)
)
@@ -250,9 +277,15 @@ async def delete_render_template(
if not tmpl:
raise HTTPException(404, detail="Render template not found")
# Delete .blend file
# Only delete .blend file if no other template shares it
blend_path = Path(tmpl.blend_file_path)
if blend_path.exists():
other_refs = await db.execute(
select(RenderTemplate.id).where(
RenderTemplate.blend_file_path == tmpl.blend_file_path,
RenderTemplate.id != template_id,
)
)
if not other_refs.first() and blend_path.exists():
blend_path.unlink(missing_ok=True)
await db.execute(sql_delete(RenderTemplate).where(RenderTemplate.id == template_id))
@@ -277,10 +310,17 @@ async def upload_blend_file(
blend_path = _blend_dir() / f"{template_id}.blend"
# Remove old file if path changed
# Only remove old file if no other template shares it
old_path = Path(tmpl.blend_file_path)
if old_path.exists() and old_path != blend_path:
old_path.unlink(missing_ok=True)
other_refs = await db.execute(
select(RenderTemplate.id).where(
RenderTemplate.blend_file_path == tmpl.blend_file_path,
RenderTemplate.id != template_id,
)
)
if not other_refs.first():
old_path.unlink(missing_ok=True)
with open(blend_path, "wb") as f:
shutil.copyfileobj(file.file, f)
+6
View File
@@ -429,14 +429,20 @@ async def scale_workers(
compose_dir = os.environ.get("COMPOSE_PROJECT_DIR", "/compose")
compose_file = os.path.join(compose_dir, "docker-compose.yml")
# Derive project name from compose dir on host (directory name = project name).
# Inside the container the compose file is at /compose, but the host project
# dir name determines the container naming prefix (e.g. "schaefflerautomat").
compose_project = os.environ.get("COMPOSE_PROJECT_NAME", "schaefflerautomat")
def _scale() -> subprocess.CompletedProcess:
return subprocess.run(
[
"docker", "compose",
"-f", compose_file,
"-p", compose_project,
"up",
"--scale", f"{body.service}={body.count}",
"--no-build",
"--no-recreate",
"-d",
],
+64
View File
@@ -9,6 +9,7 @@ Resolution chain:
3. Pass through unchanged → Blender will show FailedMaterial magenta
"""
import logging
from difflib import SequenceMatcher
from sqlalchemy import create_engine, select, func
from sqlalchemy.orm import Session, selectinload
@@ -138,3 +139,66 @@ async def seed_material_aliases_from_mappings(
await db.flush()
return {"created": created, "skipped": skipped}
async def find_unmapped_materials(
material_names: list[str], db: AsyncSession
) -> list[dict]:
"""Find material names that have no alias or library match.
Returns a list of {"raw_name": str, "suggestions": [...]} for each
unmapped name. Suggestions are the top 5 SCHAEFFLER library materials
by string similarity.
"""
if not material_names:
return []
# Load all aliases (case-insensitive lookup)
alias_rows = (await db.execute(select(MaterialAlias))).scalars().all()
alias_set: set[str] = {a.alias.lower() for a in alias_rows}
# Load all materials
mat_rows = (await db.execute(select(Material))).scalars().all()
# Library materials have a schaeffler_code
library_mats = [m for m in mat_rows if m.schaeffler_code is not None]
# All material names (case-insensitive) for exact-match check
name_lookup: dict[str, Material] = {m.name.lower(): m for m in mat_rows}
unmapped: list[dict] = []
seen: set[str] = set()
for raw_name in material_names:
raw_lower = raw_name.lower()
if raw_lower in seen:
continue
seen.add(raw_lower)
# 1. Alias match → mapped
if raw_lower in alias_set:
continue
# 2. Exact name match with a library material → mapped
matched_mat = name_lookup.get(raw_lower)
if matched_mat and matched_mat.schaeffler_code is not None:
continue
# Unmapped — compute suggestions from library materials
scored = []
for lib_mat in library_mats:
ratio = SequenceMatcher(None, raw_lower, lib_mat.name.lower()).ratio()
if ratio > 0.3:
scored.append((ratio, lib_mat))
scored.sort(key=lambda x: x[0], reverse=True)
suggestions = [
{
"id": str(m.id),
"name": m.name,
"schaeffler_code": str(m.schaeffler_code),
}
for _, m in scored[:5]
]
unmapped.append({"raw_name": raw_name, "suggestions": suggestions})
return unmapped
+18
View File
@@ -432,6 +432,24 @@ async def delete_asset_permanent(asset_id: uuid.UUID, db: AsyncSession = Depends
return {"ok": True}
@router.post("/batch-delete")
async def batch_delete_assets(
asset_ids: list[uuid.UUID],
_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Permanently delete multiple MediaAsset records."""
from app.utils.auth import require_global_admin
require_global_admin(_user)
deleted = 0
for aid in asset_ids:
ok = await service.delete_media_asset(db, aid)
if ok:
deleted += 1
return {"deleted": deleted, "requested": len(asset_ids)}
@router.post("/cleanup-orphaned")
async def cleanup_orphaned_assets(
_user: User = Depends(get_current_user),
@@ -95,9 +95,9 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
_cache_hit_asset_id = None
# Composite cache key includes deflection settings so changing them invalidates cache
# v2: tessellation now happens after mm→m scaling (fixes destroyed tessellation)
# v3: removed BRepBuilderAPI_Transform, writer handles mm→m from STEP unit metadata
effective_cache_key = (
f"v2:{_current_hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}"
f"v3:{_current_hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}"
if _current_hash else None
)
@@ -208,18 +208,26 @@ def render_order_line_task(self, order_line_id: str):
cad_name = cad_file.original_name if cad_file else "?"
# Load render_position for rotation values (per-product takes priority, falls back to global)
rotation_x = rotation_y = rotation_z = 0.0
focal_length_mm = None
sensor_width_mm = None
if line.render_position_id:
from app.models.render_position import ProductRenderPosition
rp = session.get(ProductRenderPosition, line.render_position_id)
if rp:
rotation_x, rotation_y, rotation_z = rp.rotation_x, rp.rotation_y, rp.rotation_z
emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)")
focal_length_mm = rp.focal_length_mm
sensor_width_mm = rp.sensor_width_mm
emit(order_line_id, f"Render position: '{rp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)" +
(f" focal_length={focal_length_mm}mm" if focal_length_mm else ""))
elif line.global_render_position_id:
from app.models import GlobalRenderPosition
grp = session.get(GlobalRenderPosition, line.global_render_position_id)
if grp:
rotation_x, rotation_y, rotation_z = grp.rotation_x, grp.rotation_y, grp.rotation_z
emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)")
focal_length_mm = grp.focal_length_mm
sensor_width_mm = grp.sensor_width_mm
emit(order_line_id, f"Global render position: '{grp.name}' ({rotation_x}°, {rotation_y}°, {rotation_z}°)" +
(f" focal_length={focal_length_mm}mm" if focal_length_mm else ""))
emit(order_line_id, f"Starting render for {cad_name} ({len(part_colors)} coloured parts)")
@@ -334,7 +342,10 @@ def render_order_line_task(self, order_line_id: str):
rotation_x=rotation_x,
rotation_y=rotation_y,
rotation_z=rotation_z,
camera_orbit=bool(template.camera_orbit) if template else True,
usd_path=usd_render_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
)
success = True
render_log = {
@@ -391,6 +402,8 @@ def render_order_line_task(self, order_line_id: str):
rotation_x=rotation_x,
rotation_y=rotation_y,
rotation_z=rotation_z,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
job_id=order_line_id,
order_line_id=order_line_id,
noise_threshold=noise_threshold,
+4
View File
@@ -94,6 +94,8 @@ class ProductRenderPosition(Base):
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
focal_length_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
sensor_width_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
@@ -113,6 +115,8 @@ class GlobalRenderPosition(Base):
rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
focal_length_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
sensor_width_mm: Mapped[float | None] = mapped_column(Float, nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
+12
View File
@@ -68,6 +68,8 @@ class RenderPositionCreate(BaseModel):
rotation_z: float = 0.0
is_default: bool = False
sort_order: int = 0
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
class RenderPositionPatch(BaseModel):
@@ -77,6 +79,8 @@ class RenderPositionPatch(BaseModel):
rotation_z: float | None = None
is_default: bool | None = None
sort_order: int | None = None
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
class RenderPositionOut(BaseModel):
@@ -88,6 +92,8 @@ class RenderPositionOut(BaseModel):
rotation_z: float
is_default: bool
sort_order: int
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
created_at: datetime
updated_at: datetime
@@ -101,6 +107,8 @@ class GlobalRenderPositionCreate(BaseModel):
rotation_z: float = 0.0
is_default: bool = False
sort_order: int = 0
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
class GlobalRenderPositionPatch(BaseModel):
@@ -110,6 +118,8 @@ class GlobalRenderPositionPatch(BaseModel):
rotation_z: float | None = None
is_default: bool | None = None
sort_order: int | None = None
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
class GlobalRenderPositionOut(BaseModel):
@@ -120,6 +130,8 @@ class GlobalRenderPositionOut(BaseModel):
rotation_z: float
is_default: bool
sort_order: int
focal_length_mm: float | None = None
sensor_width_mm: float | None = None
created_at: datetime
updated_at: datetime
+2 -2
View File
@@ -1,3 +1,3 @@
# Compat shim — use app.domains.materials.service instead
from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings
__all__ = ["resolve_material_map", "seed_material_aliases_from_mappings"]
from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings, find_unmapped_materials
__all__ = ["resolve_material_map", "seed_material_aliases_from_mappings", "find_unmapped_materials"]
+15
View File
@@ -92,6 +92,8 @@ def render_still(
log_callback: "Callable[[str], None] | None" = None,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
) -> dict:
"""Convert STEP → GLB (OCC or GMSH) → PNG (Blender subprocess).
@@ -179,6 +181,10 @@ def render_still(
logger.debug("[render_blender] usd_path active — mesh_attributes ignored")
elif mesh_attributes:
cmd += ["--mesh-attributes", json.dumps(mesh_attributes)]
if focal_length_mm is not None:
cmd += ["--focal-length", str(focal_length_mm)]
if sensor_width_mm is not None:
cmd += ["--sensor-width", str(sensor_width_mm)]
return cmd
def _run(eng: str) -> tuple[int, list[str], list[str]]:
@@ -311,8 +317,11 @@ def render_turntable_to_file(
rotation_x: float = 0.0,
rotation_y: float = 0.0,
rotation_z: float = 0.0,
camera_orbit: bool = True,
usd_path: "Path | None" = None,
tessellation_engine: str = "occ",
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
) -> dict:
"""Render a turntable animation: STEP → STL → N frames (Blender) → mp4 (ffmpeg).
@@ -391,8 +400,14 @@ def render_turntable_to_file(
bg_color or "",
"1" if transparent_bg else "0",
]
if camera_orbit:
cmd += ["--camera-orbit"]
if use_usd:
cmd += ["--usd-path", str(usd_path)]
if focal_length_mm is not None:
cmd += ["--focal-length", str(focal_length_mm)]
if sensor_width_mm is not None:
cmd += ["--sensor-width", str(sensor_width_mm)]
log_lines: list[str] = []
+4
View File
@@ -891,6 +891,8 @@ def render_to_file(
order_line_id: str | None = None,
usd_path: "Path | None" = None,
tessellation_engine: str | None = None,
focal_length_mm: float | None = None,
sensor_width_mm: float | None = None,
) -> tuple[bool, dict]:
"""Render a STEP file to a specific output path using current system settings.
@@ -1027,6 +1029,8 @@ def render_to_file(
log_callback=_log_cb,
usd_path=usd_path,
tessellation_engine=tessellation_engine or settings["tessellation_engine"],
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
)
rendered_png = tmp_png if tmp_png.exists() else None
except Exception as exc:
+34
View File
@@ -87,3 +87,37 @@ export async function seedAliases(): Promise<{ inserted: number; total: number }
const res = await api.post<{ inserted: number; total: number }>('/materials/seed-aliases')
return res.data
}
// --- Material check / batch alias ---
export interface MaterialSuggestion {
id: string
name: string
schaeffler_code: string
}
export interface UnmappedMaterial {
raw_name: string
suggestions: MaterialSuggestion[]
}
export interface UnmappedMaterialCheck {
unmapped: UnmappedMaterial[]
total_materials: number
mapped_count: number
}
export async function checkOrderMaterials(orderId: string): Promise<UnmappedMaterialCheck> {
const res = await api.get<UnmappedMaterialCheck>(`/orders/${orderId}/check-materials`)
return res.data
}
export async function batchCreateAliases(
mappings: Array<{ alias: string; material_id: string }>
): Promise<{ created: number; skipped: number }> {
const res = await api.post<{ created: number; skipped: number }>(
'/materials/batch-aliases',
{ mappings }
)
return res.data
}
+3
View File
@@ -133,3 +133,6 @@ export const archiveMediaAsset = (id: string): Promise<void> =>
export const deleteMediaAssetPermanent = (id: string): Promise<void> =>
api.delete(`/media/${id}/permanent`).then(() => undefined)
export const batchDeleteAssets = (ids: string[]): Promise<{ deleted: number; requested: number }> =>
api.post('/media/batch-delete', ids).then(r => r.data)
+7
View File
@@ -235,6 +235,13 @@ export async function cancelLineRender(orderId: string, lineId: string) {
return res.data
}
export async function dispatchLineRender(orderId: string, lineId: string) {
const res = await api.post<{ dispatched: boolean; line_id: string }>(
`/orders/${orderId}/lines/${lineId}/dispatch-render`
)
return res.data
}
export async function cancelOrderRenders(orderId: string) {
const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>(
`/orders/${orderId}/cancel-renders`
+6
View File
@@ -8,6 +8,8 @@ export interface GlobalRenderPosition {
rotation_z: number
is_default: boolean
sort_order: number
focal_length_mm: number | null
sensor_width_mm: number | null
created_at: string
updated_at: string
}
@@ -19,6 +21,8 @@ export interface GlobalRenderPositionCreate {
rotation_z?: number
is_default?: boolean
sort_order?: number
focal_length_mm?: number | null
sensor_width_mm?: number | null
}
export interface GlobalRenderPositionPatch {
@@ -28,6 +32,8 @@ export interface GlobalRenderPositionPatch {
rotation_z?: number
is_default?: boolean
sort_order?: number
focal_length_mm?: number | null
sensor_width_mm?: number | null
}
export async function listGlobalRenderPositions(): Promise<GlobalRenderPosition[]> {
+20
View File
@@ -39,6 +39,26 @@ export async function createRenderTemplate(formData: FormData): Promise<RenderTe
return data;
}
export async function duplicateRenderTemplate(
sourceId: string,
overrides: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit'>> & { output_type_ids?: string[] },
): Promise<RenderTemplate> {
const fd = new FormData();
fd.append('name', overrides.name || 'Untitled (copy)');
fd.append('clone_blend_from', sourceId);
fd.append('category_key', overrides.category_key || '');
fd.append('output_type_ids', (overrides.output_type_ids || []).join(','));
fd.append('target_collection', overrides.target_collection || 'Product');
fd.append('material_replace_enabled', String(overrides.material_replace_enabled ?? false));
fd.append('lighting_only', String(overrides.lighting_only ?? false));
fd.append('shadow_catcher_enabled', String(overrides.shadow_catcher_enabled ?? false));
fd.append('camera_orbit', String(overrides.camera_orbit ?? true));
const { data } = await api.post<RenderTemplate>('/render-templates', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return data;
}
export async function updateRenderTemplate(
id: string,
updates: Partial<Pick<RenderTemplate, 'name' | 'category_key' | 'output_type_ids' | 'target_collection' | 'material_replace_enabled' | 'lighting_only' | 'shadow_catcher_enabled' | 'camera_orbit' | 'is_active'>>,
+1 -1
View File
@@ -146,7 +146,7 @@ export interface CeleryWorkersResponse {
}
export interface ScaleRequest {
service: 'render-worker' | 'worker' | 'worker-thumbnail'
service: 'render-worker' | 'worker'
count: number
}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, Check, X } from 'lucide-react'
import { Plus, Pencil, Trash2, Check, X, Copy } from 'lucide-react'
import {
listGlobalRenderPositions,
createGlobalRenderPosition,
@@ -18,6 +18,7 @@ interface EditState {
rotation_z: number
is_default: boolean
sort_order: number
focal_length_mm: number | null
}
const EMPTY_EDIT: EditState = {
@@ -28,6 +29,7 @@ const EMPTY_EDIT: EditState = {
rotation_z: 0,
is_default: false,
sort_order: 0,
focal_length_mm: null,
}
export default function GlobalRenderPositionsPanel() {
@@ -66,6 +68,7 @@ export default function GlobalRenderPositionsPanel() {
rotation_z: pos.rotation_z,
is_default: pos.is_default,
sort_order: pos.sort_order,
focal_length_mm: pos.focal_length_mm,
})
}
@@ -129,6 +132,7 @@ export default function GlobalRenderPositionsPanel() {
<th className="pb-1 pr-3 text-center">Rot X°</th>
<th className="pb-1 pr-3 text-center">Rot Y°</th>
<th className="pb-1 pr-3 text-center">Rot Z°</th>
<th className="pb-1 pr-3 text-center">Focal mm</th>
<th className="pb-1 pr-3 text-center">Default</th>
<th className="pb-1 pr-3 text-center">Order</th>
<th className="pb-1" />
@@ -151,6 +155,16 @@ export default function GlobalRenderPositionsPanel() {
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
step="1"
placeholder="50"
className="input w-16 text-sm"
value={editing!.focal_length_mm ?? ''}
onChange={(e) => setEditing({ ...editing!, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
@@ -179,12 +193,32 @@ export default function GlobalRenderPositionsPanel() {
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_x}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_y}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.rotation_z}</td>
<td className="py-1.5 pr-3 text-center text-content-muted">
{pos.focal_length_mm != null ? pos.focal_length_mm : <span className="opacity-40">50</span>}
</td>
<td className="py-1.5 pr-3 text-center">
{pos.is_default && <span className="text-accent text-xs font-medium"></span>}
</td>
<td className="py-1.5 pr-3 text-center text-content-muted">{pos.sort_order}</td>
<td className="py-1.5 flex items-center gap-1">
<button className="btn btn-xs" onClick={() => startEdit(pos)}><Pencil size={12} /></button>
<button
className="btn btn-xs text-blue-500"
onClick={() => createMut.mutate({
name: `${pos.name} (copy)`,
rotation_x: pos.rotation_x,
rotation_y: pos.rotation_y,
rotation_z: pos.rotation_z,
is_default: false,
sort_order: pos.sort_order,
focal_length_mm: pos.focal_length_mm,
sensor_width_mm: pos.sensor_width_mm,
})}
disabled={createMut.isPending}
title="Duplicate"
>
<Copy size={12} />
</button>
<button
className="btn btn-xs text-red-500"
onClick={() => { if (confirm(`Delete "${pos.name}"?`)) deleteMut.mutate(pos.id) }}
@@ -213,6 +247,16 @@ export default function GlobalRenderPositionsPanel() {
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_x')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_y')}</td>
<td className="py-1 pr-2 text-center">{rotField('', 'rotation_z')}</td>
<td className="py-1 pr-2 text-center">
<input
type="number"
step="1"
placeholder="50"
className="input w-16 text-sm"
value={editing.focal_length_mm ?? ''}
onChange={(e) => setEditing({ ...editing, focal_length_mm: e.target.value ? parseFloat(e.target.value) : null })}
/>
</td>
<td className="py-1 pr-2 text-center">
<input
type="checkbox"
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X, ChevronDown } from 'lucide-react'
import { Pencil, Trash2, Plus, Check, X, ChevronDown, Copy } from 'lucide-react'
import { toast } from 'sonner'
import {
listOutputTypes, createOutputType, updateOutputType, deleteOutputType,
@@ -174,6 +174,31 @@ export default function OutputTypeTable() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to delete'),
})
const duplicateMut = useMutation({
mutationFn: (ot: OutputType) => createOutputType({
name: `${ot.name} (copy)`,
description: ot.description,
renderer: ot.renderer,
render_settings: ot.render_settings,
output_format: ot.output_format,
sort_order: ot.sort_order,
compatible_categories: ot.compatible_categories,
render_backend: ot.render_backend,
is_animation: ot.is_animation,
transparent_bg: ot.transparent_bg,
cycles_device: ot.cycles_device,
pricing_tier_id: ot.pricing_tier_id,
workflow_definition_id: ot.workflow_definition_id,
is_active: ot.is_active,
}),
onSuccess: () => {
toast.success('Output type duplicated')
qc.invalidateQueries({ queryKey: ['output-types-admin'] })
qc.invalidateQueries({ queryKey: ['output-types'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to duplicate'),
})
// Check if transparent_bg / bg_color controls should be visible
function showTransparentBg(renderer: string, _format: string) {
return renderer === 'blender'
@@ -710,6 +735,14 @@ export default function OutputTypeTable() {
>
<Pencil size={14} />
</button>
<button
className="btn-icon text-content-muted hover:text-blue-500"
onClick={() => duplicateMut.mutate(ot)}
disabled={duplicateMut.isPending}
title="Duplicate output type"
>
<Copy size={14} />
</button>
<button
className="btn-icon text-content-muted hover:text-red-500"
onClick={() => {
@@ -1,11 +1,12 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Trash2, Plus, Check, X, Upload, Download } from 'lucide-react'
import { Pencil, Trash2, Plus, Check, X, Upload, Download, Copy } from 'lucide-react'
import HelpTooltip from '../HelpTooltip'
import { toast } from 'sonner'
import {
listRenderTemplates,
createRenderTemplate,
duplicateRenderTemplate,
updateRenderTemplate,
deleteRenderTemplate,
reuploadBlendFile,
@@ -40,6 +41,7 @@ export default function RenderTemplateTable() {
const [showAdd, setShowAdd] = useState(false)
const [form, setForm] = useState(EMPTY_FORM)
const [addFile, setAddFile] = useState<File | null>(null)
const [cloneBlendFrom, setCloneBlendFrom] = useState<string>('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
const fileInputRef = useRef<HTMLInputElement>(null)
@@ -58,10 +60,14 @@ export default function RenderTemplateTable() {
const createMut = useMutation({
mutationFn: () => {
if (!addFile) throw new Error('Please select a .blend file')
if (!addFile && !cloneBlendFrom) throw new Error('Please select a .blend file or choose an existing one')
const fd = new FormData()
fd.append('name', form.name.trim())
fd.append('file', addFile)
if (addFile) {
fd.append('file', addFile)
} else if (cloneBlendFrom) {
fd.append('clone_blend_from', cloneBlendFrom)
}
fd.append('category_key', form.category_key || '')
fd.append('output_type_id', form.output_type_id || '')
fd.append('target_collection', form.target_collection || 'Product')
@@ -76,6 +82,7 @@ export default function RenderTemplateTable() {
qc.invalidateQueries({ queryKey: ['render-templates'] })
setForm(EMPTY_FORM)
setAddFile(null)
setCloneBlendFrom('')
setShowAdd(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
@@ -111,6 +118,24 @@ export default function RenderTemplateTable() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to upload'),
})
const duplicateMut = useMutation({
mutationFn: (t: RenderTemplate) => duplicateRenderTemplate(t.id, {
name: `${t.name} (copy)`,
category_key: t.category_key,
target_collection: t.target_collection,
material_replace_enabled: t.material_replace_enabled,
lighting_only: t.lighting_only,
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
output_type_ids: t.output_type_ids ?? [],
}),
onSuccess: () => {
toast.success('Template duplicated')
qc.invalidateQueries({ queryKey: ['render-templates'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to duplicate'),
})
function startEdit(t: RenderTemplate) {
setEditingId(t.id)
setEditDraft({
@@ -264,31 +289,45 @@ export default function RenderTemplateTable() {
/>
</td>
<td className="px-3 py-2">
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
<Upload size={14} />
{addFile ? addFile.name : 'Choose .blend'}
<input
ref={fileInputRef}
type="file"
accept=".blend"
className="hidden"
onChange={(e) => setAddFile(e.target.files?.[0] || null)}
/>
</label>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-1 text-xs cursor-pointer text-accent hover:text-accent-hover">
<Upload size={14} />
{addFile ? addFile.name : 'Upload .blend'}
<input
ref={fileInputRef}
type="file"
accept=".blend"
className="hidden"
onChange={(e) => { setAddFile(e.target.files?.[0] || null); setCloneBlendFrom('') }}
/>
</label>
{!addFile && (
<select
className={inputCls + ' text-xs w-32'}
value={cloneBlendFrom}
onChange={(e) => { setCloneBlendFrom(e.target.value); setAddFile(null) }}
>
<option value="">or re-use existing</option>
{templates?.map((t) => (
<option key={t.id} value={t.id}>{t.original_filename} ({t.name})</option>
))}
</select>
)}
</div>
</td>
<td />
<td className="px-3 py-2">
<div className="flex gap-1">
<button
onClick={() => createMut.mutate()}
disabled={!form.name.trim() || !addFile || createMut.isPending}
disabled={!form.name.trim() || (!addFile && !cloneBlendFrom) || createMut.isPending}
className="p-1 text-status-success-text hover:bg-surface-hover rounded disabled:opacity-40"
title="Create"
>
<Check size={16} />
</button>
<button
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null) }}
onClick={() => { setShowAdd(false); setForm(EMPTY_FORM); setAddFile(null); setCloneBlendFrom('') }}
className="p-1 text-content-muted hover:bg-surface-hover rounded"
title="Cancel"
>
@@ -446,6 +485,9 @@ export default function RenderTemplateTable() {
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{templates && templates.filter((o) => o.blend_file_path === t.blend_file_path).length > 1 && (
<span className="text-xs text-blue-500" title="Shared .blend file">&#8727;</span>
)}
<span className="text-xs text-content-secondary truncate max-w-[120px]" title={t.original_filename}>
{t.original_filename}
</span>
@@ -495,6 +537,9 @@ export default function RenderTemplateTable() {
<button onClick={() => startEdit(t)} className="p-1 text-accent hover:bg-surface-hover rounded" title="Edit">
<Pencil size={14} />
</button>
<button onClick={() => duplicateMut.mutate(t)} disabled={duplicateMut.isPending} className="p-1 text-blue-500 hover:bg-blue-50 rounded" title="Duplicate">
<Copy size={14} />
</button>
<button
onClick={() => {
if (confirm(`Delete template "${t.name}"?`)) deleteMut.mutate(t.id)
@@ -13,6 +13,7 @@ import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
import WebGLErrorBoundary from './WebGLErrorBoundary'
type ViewMode = 'solid' | 'wireframe'
type LightPreset = 'studio' | 'warehouse' | 'sunset' | 'park' | 'city'
@@ -518,6 +519,7 @@ export default function InlineCadViewer({
{/* ── Canvas area ── */}
<div className="flex-1 relative" onClick={(e) => e.stopPropagation()}>
<WebGLErrorBoundary>
<Canvas
gl={{ powerPreference: 'high-performance', antialias: true }}
dpr={[1, 1.5]}
@@ -571,6 +573,7 @@ export default function InlineCadViewer({
<OrbitControls ref={controlsRef} makeDefault />
<CameraAutoFit sceneRef={sceneRef} controlsRef={controlsRef} fitTrigger={fitTrigger} />
</Canvas>
</WebGLErrorBoundary>
{/* Material assignment panel */}
{pinnedPart && (
@@ -35,6 +35,7 @@ import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
import WebGLErrorBoundary from './WebGLErrorBoundary'
// ---------------------------------------------------------------------------
// Types
@@ -1033,6 +1034,7 @@ export default function ThreeDViewer({
F fit · W wire · G grid · S shadow · click part to assign · Esc close
</div>
<WebGLErrorBoundary>
<Canvas
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
dpr={[1, 1.5]}
@@ -1135,6 +1137,7 @@ export default function ThreeDViewer({
/>
)}
</Canvas>
</WebGLErrorBoundary>
</div>
</div>
)
@@ -0,0 +1,43 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
/**
* Wraps <Canvas> from @react-three/fiber to catch WebGL context creation
* failures (e.g. Chrome GPU sandbox) and show a graceful fallback instead
* of crashing the entire React tree.
*/
export default class WebGLErrorBoundary extends Component<
{ children: ReactNode },
{ error: string | null }
> {
constructor(props: { children: ReactNode }) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): { error: string } {
return { error: error.message || 'WebGL context could not be created' }
}
componentDidCatch(error: Error, _info: ErrorInfo): void {
console.warn('[WebGLErrorBoundary]', error.message)
}
render(): ReactNode {
if (this.state.error) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 text-white gap-3 p-8 text-center">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-yellow-400">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" /><path d="M12 17h.01" />
</svg>
<p className="text-lg font-medium">3D Viewer unavailable</p>
<p className="text-sm text-gray-400 max-w-md">
Your browser could not create a WebGL context. This may be caused by GPU sandbox restrictions or missing graphics drivers.
Try a different browser or launch Chrome with <code className="bg-gray-800 px-1 rounded">--disable-gpu-sandbox</code>.
</p>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import Modal from '../shared/Modal'
import {
listMaterials,
batchCreateAliases,
type UnmappedMaterial,
type Material,
} from '../../api/materials'
interface Props {
unmapped: UnmappedMaterial[]
onResolved: () => void
onCancel: () => void
}
export default function UnmappedMaterialsDialog({ unmapped, onResolved, onCancel }: Props) {
const [mappings, setMappings] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// Load all library materials for the "all materials" fallback in dropdowns
const { data: allMaterials } = useQuery({
queryKey: ['materials'],
queryFn: listMaterials,
})
const libraryMaterials = (allMaterials ?? []).filter(
(m: Material) => m.schaeffler_code !== null
)
const allMapped = unmapped.every((u) => mappings[u.raw_name])
async function handleProceed() {
setSaving(true)
setError(null)
try {
const batch = unmapped.map((u) => ({
alias: u.raw_name,
material_id: mappings[u.raw_name],
}))
await batchCreateAliases(batch)
onResolved()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to create aliases')
} finally {
setSaving(false)
}
}
return (
<Modal title="Unmapped Materials" onClose={onCancel} size="lg">
<div className="p-6 space-y-4">
{/* Warning banner */}
<div className="flex items-start gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-content-secondary">
The following materials have no alias to a library material.
Map them before rendering to avoid magenta placeholder materials.
</p>
</div>
{/* Mapping table */}
<div className="space-y-3">
{unmapped.map((u) => (
<div
key={u.raw_name}
className="flex items-center gap-4 p-3 rounded-lg border border-border-default"
>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-content truncate block">
{u.raw_name}
</span>
</div>
<div className="w-72 shrink-0">
<select
className="w-full text-sm rounded-md border border-border-default px-3 py-1.5 text-content"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value={mappings[u.raw_name] ?? ''}
onChange={(e) =>
setMappings((prev) => ({ ...prev, [u.raw_name]: e.target.value }))
}
>
<option value="">Select library material...</option>
{/* Suggestions first */}
{u.suggestions.length > 0 && (
<optgroup label="Suggestions">
{u.suggestions.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</optgroup>
)}
{/* All library materials */}
<optgroup label="All Library Materials">
{libraryMaterials
.sort((a: Material, b: Material) => a.name.localeCompare(b.name))
.map((m: Material) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</optgroup>
</select>
</div>
</div>
))}
</div>
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={onCancel}
className="px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-muted"
>
Cancel
</button>
<button
onClick={handleProceed}
disabled={!allMapped || saving}
className="px-4 py-2 text-sm rounded-md bg-brand text-white hover:bg-brand-hover disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Map All & Proceed'}
</button>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,107 @@
import { useEffect, useCallback } from 'react'
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react'
export interface LightboxItem {
url: string
label?: string
}
interface Props {
items: LightboxItem[]
index: number
onClose: () => void
onIndexChange: (i: number) => void
}
export default function ImageLightbox({ items, index, onClose, onIndexChange }: Props) {
const item = items[index]
const hasPrev = index > 0
const hasNext = index < items.length - 1
const prev = useCallback(() => { if (hasPrev) onIndexChange(index - 1) }, [hasPrev, index, onIndexChange])
const next = useCallback(() => { if (hasNext) onIndexChange(index + 1) }, [hasNext, index, onIndexChange])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
else if (e.key === 'ArrowLeft') prev()
else if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose, prev, next])
// Prevent body scroll while open
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
if (!item) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={onClose}
>
{/* Close */}
<button
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
onClick={onClose}
title="Close (Esc)"
>
<X size={20} />
</button>
{/* Counter + label */}
<div className="absolute top-4 left-4 text-white/70 text-sm z-10 flex items-center gap-3">
<span>{index + 1} / {items.length}</span>
{item.label && <span className="text-white/50">{item.label}</span>}
</div>
{/* Download */}
<a
href={item.url}
download
target="_blank"
rel="noopener noreferrer"
className="absolute top-4 right-16 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
onClick={(e) => e.stopPropagation()}
title="Download"
>
<Download size={18} />
</a>
{/* Prev arrow */}
{hasPrev && (
<button
className="absolute left-3 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
onClick={(e) => { e.stopPropagation(); prev() }}
title="Previous"
>
<ChevronLeft size={28} />
</button>
)}
{/* Next arrow */}
{hasNext && (
<button
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 text-white hover:bg-white/20 transition-colors z-10"
onClick={(e) => { e.stopPropagation(); next() }}
title="Next"
>
<ChevronRight size={28} />
</button>
)}
{/* Image */}
<img
src={item.url}
alt={item.label || 'Render'}
className="max-h-[90vh] max-w-[90vw] object-contain select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
</div>
)
}
+23
View File
@@ -243,6 +243,12 @@ export default function AdminPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const purgeRenderMediaMut = useMutation({
mutationFn: () => api.delete('/admin/settings/purge-render-media'),
onSuccess: (res) => toast.success(res.data.message || 'Render media purged'),
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed'),
})
const [smtpDraft, setSmtpDraft] = useState<Partial<Settings>>({})
const smtp = { ...settings, ...smtpDraft } as Settings
@@ -1017,6 +1023,23 @@ export default function AdminPage() {
</button>
<p className="text-xs text-content-muted">Removes STEP files, thumbnails, and DB records not linked to any product.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => setConfirmState({
open: true,
title: 'Purge All Rendered Media',
message: 'Delete ALL still renders and turntable animations? Thumbnails, GLBs, and USD masters are kept. This cannot be undone.',
onConfirm: () => { purgeRenderMediaMut.mutate(); setConfirmState(s => ({ ...s, open: false })) },
})}
disabled={purgeRenderMediaMut.isPending}
className="btn-secondary text-sm w-full justify-start text-red-500"
title="Delete all still and turntable render media (files + DB records)"
>
<Trash2 size={14} className={purgeRenderMediaMut.isPending ? 'animate-spin' : ''} />
{purgeRenderMediaMut.isPending ? 'Purging…' : 'Purge All Stills & Turntables'}
</button>
<p className="text-xs text-content-muted">Deletes all rendered images and animations. Thumbnails, GLBs, and USD files are preserved.</p>
</div>
<div className="flex flex-col gap-1">
<button
onClick={() => reextractMetadataMut.mutate()}
+63
View File
@@ -4,10 +4,12 @@ import { toast } from 'sonner'
import {
Plus, Trash2, Pencil, Check, X, FlaskConical, Search, Wand2, Download,
Wrench, Paintbrush, Shapes, HelpCircle, ChevronDown, ChevronRight, Tag,
AlertTriangle, Link,
} from 'lucide-react'
import {
listMaterials, createMaterial, updateMaterial, deleteMaterial,
seedSchaefflerMaterials, addAlias, deleteAlias, seedAliases,
batchCreateAliases,
} from '../api/materials'
import type { Material } from '../api/materials'
import MaterialWizard from '../components/MaterialWizard'
@@ -132,6 +134,25 @@ export default function MaterialsPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to remove alias'),
})
const [quickMapTarget, setQuickMapTarget] = useState<Record<string, string>>({})
const quickMapMut = useMutation({
mutationFn: ({ alias, material_id }: { alias: string; material_id: string }) =>
batchCreateAliases([{ alias, material_id }]),
onSuccess: () => {
toast.success('Alias created')
qc.invalidateQueries({ queryKey: ['materials'] })
setQuickMapTarget({})
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create alias'),
})
// Library materials (have schaeffler_code) for quick-map dropdown
const libraryMaterials = useMemo(
() => materials.filter((m) => m.schaeffler_code !== null).sort((a, b) => a.name.localeCompare(b.name)),
[materials]
)
const startEdit = (mat: Material) => {
setEditingId(mat.id)
setEditName(mat.name)
@@ -389,6 +410,48 @@ export default function MaterialsPage() {
{mat.schaeffler_code != null && (
<p className="text-xs text-content-muted font-mono">Nr: {mat.schaeffler_code}</p>
)}
{mat.schaeffler_code == null && mat.aliases.length === 0 && (
<div className="flex items-center gap-1.5 mt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
<AlertTriangle size={10} /> No alias
</span>
{quickMapTarget[mat.id] !== undefined ? (
<div className="flex items-center gap-1">
<select
className="text-[10px] border border-border-default rounded px-1 py-0.5"
style={{ backgroundColor: 'var(--color-bg-surface)' }}
value={quickMapTarget[mat.id] ?? ''}
onChange={(e) => setQuickMapTarget((prev) => ({ ...prev, [mat.id]: e.target.value }))}
>
<option value="">Select target...</option>
{libraryMaterials.map((lm) => (
<option key={lm.id} value={lm.id}>{lm.name}</option>
))}
</select>
<button
onClick={() => quickMapTarget[mat.id] && quickMapMut.mutate({ alias: mat.name, material_id: quickMapTarget[mat.id] })}
disabled={!quickMapTarget[mat.id] || quickMapMut.isPending}
className="text-[10px] text-accent hover:text-accent-hover font-medium disabled:opacity-40"
>
Map
</button>
<button
onClick={() => setQuickMapTarget((prev) => { const n = { ...prev }; delete n[mat.id]; return n })}
className="text-[10px] text-content-muted hover:text-content"
>
<X size={10} />
</button>
</div>
) : (
<button
onClick={() => setQuickMapTarget((prev) => ({ ...prev, [mat.id]: '' }))}
className="inline-flex items-center gap-0.5 text-[10px] text-accent hover:text-accent-hover font-medium"
>
<Link size={10} /> Map to library
</button>
)}
</div>
)}
</div>
<div className="min-w-0">
<p className="text-sm text-content-muted truncate">{mat.description || '—'}</p>
+80 -7
View File
@@ -1,14 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Search, Image, Film, Box, Layers, FileCode2,
ChevronLeft, ChevronRight, Download, Loader2,
CheckSquare, Square, X, ZoomIn, Archive,
CheckSquare, Square, X, ZoomIn, Archive, Trash2,
} from 'lucide-react'
import {
getMediaAssets,
zipDownloadAssets,
batchDeleteAssets,
} from '../api/media'
import { toast } from 'sonner'
import type { MediaAssetItem, MediaAssetType } from '../api/media'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -142,7 +145,15 @@ function Lightbox({ asset, onClose }: { asset: MediaAssetItem; onClose: () => vo
>
<div className="flex items-center justify-between max-w-5xl mx-auto">
<div className="space-y-0.5">
{asset.product_name && <p className="font-medium">{asset.product_name}</p>}
{asset.product_name && (
asset.product_id ? (
<Link to={`/products/${asset.product_id}`} className="font-medium hover:underline">
{asset.product_name}
</Link>
) : (
<p className="font-medium">{asset.product_name}</p>
)
)}
<p className="text-xs opacity-70">
{asset.asset_type}
{asset.product_pim_id && ` · ${asset.product_pim_id}`}
@@ -268,9 +279,20 @@ function AssetCard({ asset, selected, onToggleSelect, onPreview }: AssetCardProp
)}
</div>
{asset.product_name && (
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
{asset.product_name}
</p>
asset.product_id ? (
<Link
to={`/products/${asset.product_id}`}
className="text-xs font-medium text-accent hover:text-accent-hover truncate block"
title={asset.product_name}
onClick={e => e.stopPropagation()}
>
{asset.product_name}
</Link>
) : (
<p className="text-xs font-medium text-content truncate" title={asset.product_name}>
{asset.product_name}
</p>
)
)}
{asset.product_pim_id && (
<p className="text-xs text-content-muted font-mono truncate">{asset.product_pim_id}</p>
@@ -326,6 +348,8 @@ export default function MediaBrowserPage() {
// Selection
const [selected, setSelected] = useState<Set<string>>(new Set())
const [zipping, setZipping] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
// Lightbox
const [previewAsset, setPreviewAsset] = useState<MediaAssetItem | null>(null)
@@ -379,6 +403,8 @@ export default function MediaBrowserPage() {
}
}
const qc = useQueryClient()
async function handleZipDownload() {
if (selected.size === 0) return
setZipping(true)
@@ -389,6 +415,22 @@ export default function MediaBrowserPage() {
}
}
async function handleBatchDelete() {
if (selected.size === 0) return
setDeleting(true)
try {
const result = await batchDeleteAssets(Array.from(selected))
toast.success(`Deleted ${result.deleted} asset${result.deleted !== 1 ? 's' : ''}`)
setSelected(new Set())
setConfirmDelete(false)
qc.invalidateQueries({ queryKey: ['media-browser'] })
} catch {
toast.error('Failed to delete assets')
} finally {
setDeleting(false)
}
}
return (
<div className="flex flex-col h-full">
{/* Lightbox */}
@@ -553,8 +595,39 @@ export default function MediaBrowserPage() {
: <><Archive size={14} /> Download ZIP</>
}
</button>
<div className="w-px h-5 bg-border-default" />
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-sm text-red-500 font-medium">Delete {selected.size} asset{selected.size !== 1 ? 's' : ''}?</span>
<button
onClick={handleBatchDelete}
disabled={deleting}
className="flex items-center gap-1 text-sm font-medium text-white bg-red-500 hover:bg-red-600 px-2.5 py-1 rounded transition-colors disabled:opacity-50"
>
{deleting
? <><Loader2 size={12} className="animate-spin" /> Deleting</>
: <><Trash2 size={12} /> Confirm</>
}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="text-sm text-content-muted hover:text-content transition-colors disabled:opacity-50"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 text-sm font-medium text-red-500 hover:text-red-400 transition-colors"
>
<Trash2 size={14} /> Delete
</button>
)}
<div className="w-px h-5 bg-border-default" />
<button
onClick={() => setSelected(new Set())}
onClick={() => { setSelected(new Set()); setConfirmDelete(false) }}
className="flex items-center gap-1 text-sm text-content-muted hover:text-content transition-colors"
>
<X size={14} />
+69 -10
View File
@@ -12,7 +12,9 @@ import {
XCircle, RotateCw, Info,
} from 'lucide-react'
import { toast } from 'sonner'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders'
import { checkOrderMaterials, type UnmappedMaterial } from '../api/materials'
import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog'
import type { OrderItem, OrderLine } from '../api/orders'
import { listOutputTypes } from '../api/outputTypes'
import type { OutputType } from '../api/outputTypes'
@@ -63,6 +65,9 @@ export default function OrderDetailPage() {
const [genLinesOpen, setGenLinesOpen] = useState(false)
const [genLinesSelected, setGenLinesSelected] = useState<Record<string, boolean>>({})
const [isDownloading, setIsDownloading] = useState(false)
const [unmappedMaterials, setUnmappedMaterials] = useState<UnmappedMaterial[]>([])
const [showMaterialDialog, setShowMaterialDialog] = useState(false)
const [checkingMaterials, setCheckingMaterials] = useState(false)
const [rejectModalOpen, setRejectModalOpen] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [rejectNotifyClient, setRejectNotifyClient] = useState(true)
@@ -105,6 +110,24 @@ export default function OrderDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Dispatch failed'),
})
async function handleDispatch() {
setCheckingMaterials(true)
try {
const result = await checkOrderMaterials(id!)
if (result.unmapped.length > 0) {
setUnmappedMaterials(result.unmapped)
setShowMaterialDialog(true)
} else {
dispatchMut.mutate()
}
} catch {
// If check fails, proceed with dispatch anyway
dispatchMut.mutate()
} finally {
setCheckingMaterials(false)
}
}
const cancelAllMut = useMutation({
mutationFn: () => cancelOrderRenders(id!),
onSuccess: (data) => {
@@ -288,18 +311,20 @@ export default function OrderDetailPage() {
)}
{canDispatch && (
<button
onClick={() => dispatchMut.mutate()}
onClick={handleDispatch}
className="btn-secondary"
disabled={dispatchMut.isPending}
disabled={dispatchMut.isPending || checkingMaterials}
>
{order.status === 'completed' ? <RefreshCw size={16} /> : rp && rp.failed > 0 ? <RefreshCw size={16} /> : <Play size={16} />}
{dispatchMut.isPending
? 'Dispatching…'
: order.status === 'completed'
? 'Re-submit Renders'
: rp && rp.failed > 0
? 'Retry Failed'
: 'Dispatch Renders'}
{checkingMaterials
? 'Checking materials…'
: dispatchMut.isPending
? 'Dispatching…'
: order.status === 'completed'
? 'Re-submit Renders'
: rp && rp.failed > 0
? 'Retry Failed'
: 'Dispatch Renders'}
</button>
)}
{canReject && (
@@ -753,6 +778,18 @@ export default function OrderDetailPage() {
)}
</div>
{/* Unmapped Materials Dialog */}
{showMaterialDialog && (
<UnmappedMaterialsDialog
unmapped={unmappedMaterials}
onResolved={() => {
setShowMaterialDialog(false)
dispatchMut.mutate()
}}
onCancel={() => setShowMaterialDialog(false)}
/>
)}
{/* Reject Order Modal */}
{rejectModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
@@ -846,6 +883,15 @@ function OrderLineRow({
onError: (e: any) => toast.error(e.response?.data?.detail || 'Cancel failed'),
})
const dispatchLineMut = useMutation({
mutationFn: () => dispatchLineRender(orderId, line.id),
onSuccess: () => {
toast.success('Render re-submitted')
qc.invalidateQueries({ queryKey: ['order', orderId] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Re-submit failed'),
})
const rejectLineMut = useMutation({
mutationFn: () => rejectOrderLine(orderId, line.id, rejectLineReason),
onSuccess: () => {
@@ -986,6 +1032,19 @@ function OrderLineRow({
{cancelMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Ban size={12} />}
</button>
)}
{isPrivileged && (line.render_status === 'failed' || line.render_status === 'cancelled' || line.render_status === 'pending') && line.output_type_id && (
<button
onClick={(e) => {
e.stopPropagation()
dispatchLineMut.mutate()
}}
disabled={dispatchLineMut.isPending}
className="text-content-muted hover:text-green-500 transition-colors"
title="Re-submit this render"
>
{dispatchLineMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <RotateCw size={12} />}
</button>
)}
{line.render_log && (
<button
onClick={(e) => {
+90 -17
View File
@@ -11,7 +11,7 @@ import {
getProduct, updateProduct, uploadProductCad, saveProductCadMaterials, regenerateProduct,
reprocessProduct, reassignMaterialsFromExcel, getProductOrders, getProductRenders,
createRenderPosition, updateRenderPosition, deleteRenderPosition, deleteProductRender,
downloadProductRenders,
downloadProductRenders, deleteProduct,
} from '../api/products'
import type { Product, CadPartMaterial, ProductRender, RenderPosition } from '../api/products'
import { listMaterials } from '../api/materials'
@@ -20,10 +20,12 @@ import MaterialInput from '../components/shared/MaterialInput'
import MaterialWizard from '../components/MaterialWizard'
import { useAuthStore, isAdmin as checkIsAdmin, isPrivileged as checkIsPrivileged } from '../store/auth'
import { generateGltfGeometry, resetStuckProcessing } from '../api/cad'
import { triggerUsdMasterGeneration } from '../api/sceneManifest'
import { listMediaAssets as getMediaAssets } from '../api/media'
import InlineCadViewer from '../components/cad/InlineCadViewer'
import { convertCadPartMaterials, normalizeMeshName } from '../components/cad/cadUtils'
import RenderInfoModal from '../components/renders/RenderInfoModal'
import ImageLightbox, { type LightboxItem } from '../components/shared/ImageLightbox'
function GlbDownloadButton({
label, url, filename, onGenerate, isGenerating, title,
@@ -147,6 +149,7 @@ export default function ProductDetailPage() {
const [wizardOpen, setWizardOpen] = useState(false)
const [wizardTargetIdx, setWizardTargetIdx] = useState<number | null>(null)
const [showCadInfo, setShowCadInfo] = useState(false)
const [confirmDeleteProduct, setConfirmDeleteProduct] = useState(false)
const { data: product, isLoading } = useQuery({
queryKey: ['product', id],
@@ -221,6 +224,7 @@ export default function ProductDetailPage() {
const [batchLoading, setBatchLoading] = useState(false)
const [filterOutputType, setFilterOutputType] = useState<string | null>(null)
const [downloadLoading, setDownloadLoading] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const outputTypeNames = useMemo(() => {
const names = renders.map(r => r.output_type_name).filter((n): n is string => n !== null)
@@ -232,6 +236,15 @@ export default function ProductDetailPage() {
return renders.filter(r => r.output_type_name === filterOutputType)
}, [renders, filterOutputType])
// Build lightbox items from filtered image-only renders
const lightboxItems: LightboxItem[] = useMemo(
() => filteredRenders.filter(r => !r.is_video).map(r => ({
url: r.render_url,
label: [r.render_position_name, r.output_type_name].filter(Boolean).join(' — '),
})),
[filteredRenders],
)
const toggleSelect = (lineId: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
@@ -325,6 +338,15 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Update failed'),
})
const deleteProductMut = useMutation({
mutationFn: () => deleteProduct(id!, true),
onSuccess: () => {
toast.success('Product deleted permanently')
navigate('/products')
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Delete failed'),
})
const cadUploadMut = useMutation({
mutationFn: (file: File) => uploadProductCad(id!, file),
onSuccess: () => {
@@ -360,6 +382,15 @@ export default function ProductDetailPage() {
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue GLB export'),
})
const generateUsdMasterMut = useMutation({
mutationFn: () => triggerUsdMasterGeneration(product!.cad_file_id!),
onSuccess: () => {
toast.info('USD master generation queued')
qc.invalidateQueries({ queryKey: ['media-assets', cadFileId, 'usd_master'] })
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to queue USD master'),
})
const resetStuckMut = useMutation({
mutationFn: () => resetStuckProcessing(product!.cad_file_id!),
onSuccess: (res) => {
@@ -504,12 +535,40 @@ export default function ProductDetailPage() {
</button>
</>
) : (
<button
className="btn-secondary text-sm"
onClick={() => setEditMode(true)}
>
<Pencil size={14} /> Edit
</button>
<>
<button
className="btn-secondary text-sm"
onClick={() => setEditMode(true)}
>
<Pencil size={14} /> Edit
</button>
{confirmDeleteProduct ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-500 font-medium">Delete permanently?</span>
<button
onClick={() => deleteProductMut.mutate()}
disabled={deleteProductMut.isPending}
className="px-2.5 py-1.5 text-xs font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors disabled:opacity-50 flex items-center gap-1"
>
{deleteProductMut.isPending ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
Confirm
</button>
<button
onClick={() => setConfirmDeleteProduct(false)}
className="text-xs text-content-muted hover:text-content"
>
Cancel
</button>
</div>
) : (
<button
className="px-3 py-2 rounded-lg text-sm font-medium border border-red-300 text-red-500 hover:bg-red-50 transition-colors flex items-center gap-1.5"
onClick={() => setConfirmDeleteProduct(true)}
>
<Trash2 size={14} /> Delete
</button>
)}
</>
)}
</div>
)}
@@ -742,9 +801,9 @@ export default function ProductDetailPage() {
label="USD Master"
url={usdMasterUrl}
filename={`${product.name ?? product.pim_id}_master.usd`}
onGenerate={() => generateGeometryGlbMut.mutate()}
isGenerating={generateGeometryGlbMut.isPending}
title="USD canonical scene (auto-generated after Viewer GLB)"
onGenerate={() => generateUsdMasterMut.mutate()}
isGenerating={generateUsdMasterMut.isPending}
title="Regenerate USD canonical scene"
/>
</div>
</>
@@ -999,10 +1058,12 @@ export default function ProductDetailPage() {
</p>
) : (
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{filteredRenders.map((r) => {
{filteredRenders.map((r, _ri) => {
const isConfirming = pendingDelete === r.order_line_id
const isDeleting = deleteRenderMut.isPending && isConfirming
const isSelected = selectedIds.has(r.order_line_id)
// Index into lightboxItems (image-only renders)
const imgIdx = r.is_video ? -1 : lightboxItems.findIndex(li => li.url === r.render_url)
return (
<div
key={r.order_line_id}
@@ -1048,11 +1109,14 @@ export default function ProductDetailPage() {
</div>
) : (
<div className="relative group">
<a
href={r.render_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => selectMode && e.preventDefault()}
<button
type="button"
className="w-full cursor-pointer"
onClick={(e) => {
if (selectMode) return
e.stopPropagation()
if (imgIdx >= 0) setLightboxIndex(imgIdx)
}}
>
<img
src={r.render_url}
@@ -1061,7 +1125,7 @@ export default function ProductDetailPage() {
isConfirming ? 'opacity-30' : isSelected ? 'opacity-80' : 'hover:opacity-90'
}`}
/>
</a>
</button>
{/* Select mode: checkbox top-left */}
{selectMode && (
@@ -1451,6 +1515,15 @@ export default function ProductDetailPage() {
title="CAD Thumbnail"
renderLog={product.cad_render_log}
/>
{/* Image lightbox */}
{lightboxIndex !== null && (
<ImageLightbox
items={lightboxItems}
index={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
/>
)}
</div>
)
}
+2 -3
View File
@@ -80,9 +80,8 @@ function WorkerCard({ worker }: { worker: CeleryWorker }) {
type ScalableService = ScaleRequest['service']
const SCALABLE_SERVICES: { service: ScalableService; label: string; description: string }[] = [
{ service: 'render-worker', label: 'Render Worker', description: 'Blender renders — concurrency=1' },
{ service: 'worker', label: 'Step Worker', description: 'STEP processing — concurrency=8' },
{ service: 'worker-thumbnail', label: 'Thumbnail Worker', description: 'Thumbnail rendering' },
{ service: 'render-worker', label: 'Render Worker (asset_pipeline)', description: 'Blender renders, thumbnails, GLB, USD — concurrency=1' },
{ service: 'worker', label: 'Step Worker (step_processing)', description: 'STEP metadata extraction — concurrency=8' },
]
function ScaleControl({
+160 -120
View File
@@ -1,151 +1,191 @@
# Plan: Draw Call Batching + Merge Dual STEP Parse
# Plan: Material Alias Completeness with Blocking Dialog
## Context
Two independent optimization tracks:
The material system currently has three resolution tiers in `resolve_material_map()`:
1. **Alias lookup** (case-insensitive) — maps raw names to SCHAEFFLER library materials
2. **Exact Material.name match** — the raw name IS a library material
3. **Pass-through** — unresolved names fall through, causing magenta "FailedMaterial" in Blender renders
**Track A — Draw Call Batching (Frontend):** Assemblies with 100+ parts create 100+ draw calls. Three.js issues one draw call per mesh. For large assemblies this saturates the GPU command buffer and drops frame rate below 30fps. Solution: merge meshes that share the same material into single geometries, togglable via a "Performance mode" button.
Materials are stored on `Product.cad_part_materials` as `[{part_name, material}]` JSONB. They are auto-populated from Excel components during STEP processing (`_auto_populate_materials_for_cad`). The Materials page shows "Custom" materials (no `schaeffler_code`) separately from categorized SCHAEFFLER library materials.
**Track B — Merge Dual STEP Parse (Backend):** `extract_cad_metadata()` reads the same STEP file twice:
1. `_extract_step_objects()``OCC.Core.STEPCAFControl_Reader` → part names (lines 391425)
2. `extract_mesh_edge_data()``OCP.STEPControl.STEPControl_Reader` → tessellates, extracts edge topology + bbox (lines 200388)
**Problem**: When a product has a material name like "Steel--Stahl" that has no alias pointing to a SCHAEFFLER library material, the render silently produces magenta parts. There is no pre-flight check before dispatching renders.
Both readers produce a `TopoDS_Shape`. The XCAF reader (`STEPCAFControl`) gives us both the labeled hierarchy AND the shape, so we can extract edge data from the same read. This eliminates ~0.52s of redundant STEP parsing per file.
**Solution**: Add a blocking dialog at "Dispatch Renders" that checks for unmapped materials, lets the user map them inline to library materials, and only proceeds when all materials are resolved.
**Important constraint for Track B:** `_extract_step_objects` runs on the `worker` container (has `OCC.Core` / pythonocc), while `extract_mesh_edge_data` has dual-import fallback (`OCP` first, then `OCC.Core`). The unified function must work with `OCC.Core` (pythonocc) since that's what the `worker` container has.
### Architecture
```
Frontend (OrderDetail.tsx)
|
|-- Click "Dispatch Renders"
|-- Call GET /api/orders/{id}/check-materials (new endpoint)
|-- If unmapped materials exist:
| |-- Show UnmappedMaterialsDialog (new component)
| |-- User maps each material via dropdown
| |-- Call POST /api/materials/batch-aliases (new endpoint)
| |-- On success, proceed with dispatchRenders()
|-- If all mapped: proceed directly
```
## Affected Files
| File | Track | Change |
|------|-------|--------|
| `frontend/src/components/cad/useGeometryMerge.ts` | A | NEW — hook for merge/unmerge logic |
| `frontend/src/components/cad/ThreeDViewer.tsx` | A | Add Performance mode toggle + integrate hook |
| `frontend/src/components/cad/InlineCadViewer.tsx` | A | Same Performance mode toggle |
| `frontend/src/components/cad/cadUtils.ts` | A | Add `MergedGroup` type |
| `backend/app/services/step_processor.py` | B | New `extract_step_metadata()`, refactor callers |
| `backend/app/domains/pipeline/tasks/extract_metadata.py` | B | Use new unified function |
| File | Change |
|------|--------|
| `backend/app/services/material_service.py` | Add `find_unmapped_materials()` function |
| `backend/app/api/routers/orders.py` | Add `GET /orders/{id}/check-materials` endpoint |
| `backend/app/api/routers/materials.py` | Add `POST /materials/batch-aliases` endpoint |
| `frontend/src/api/materials.ts` | Add `checkOrderMaterials()`, `batchCreateAliases()` API functions + types |
| `frontend/src/components/orders/UnmappedMaterialsDialog.tsx` | New blocking dialog component |
| `frontend/src/pages/OrderDetail.tsx` | Intercept "Dispatch Renders" with material check |
| `frontend/src/pages/Materials.tsx` | Add warning badges for unmapped custom materials |
## Tasks (in order)
---
### [x] Task 1: Backend — `find_unmapped_materials()` service function
### Track A — Draw Call Batching
- **File**: `backend/app/services/material_service.py`
- **What**: Add an async function `find_unmapped_materials(material_names: list[str], db: AsyncSession) -> list[dict]` that:
1. Takes a list of raw material name strings
2. Loads all `MaterialAlias` records and all `Material` records with `schaeffler_code`
3. For each raw name, checks: (a) alias match (case-insensitive), (b) exact `Material.name` match where it has a `schaeffler_code` (i.e. it IS a library material)
4. Returns a list of `{"raw_name": str, "suggestions": [{"id": str, "name": str, "schaeffler_code": str}]}` for each unmapped name
5. `suggestions`: top 5 SCHAEFFLER materials by `difflib.SequenceMatcher` similarity (ratio > 0.3)
- **Acceptance gate**: Returns empty list when all materials are mapped; returns unmapped entries with suggestions when some are not
- **Dependencies**: None
- **Risk**: None
### [ ] Task A1: Add `MergedGroup` type and merge utility to cadUtils.ts
- **File**: `frontend/src/components/cad/cadUtils.ts`
- **What**: Add type `MergedGroup = { mergedMesh: any; sourceEntries: MeshRegistryEntry[]; materialKey: string }`. Add helper `groupRegistryByMaterial(registry: MeshRegistryEntry[], partMaterials: PartMaterialMap, pbrMap: MaterialPBRMap): Map<string, MeshRegistryEntry[]>` that groups registry entries by their resolved material name (or `__unassigned__` for parts without material).
- **Acceptance gate**: TypeScript compiles (`tsc --noEmit`). Helper is pure — no side effects, no THREE import.
- **Dependencies**: none
### [x] Task 2: Backend — `GET /orders/{id}/check-materials` endpoint
- **File**: `backend/app/api/routers/orders.py`
- **What**: Add endpoint that:
1. Loads all `OrderLine` records for the order, joining `Product`
2. Collects all unique material names from `product.cad_part_materials[*].material` across all products
3. Calls `find_unmapped_materials()` with those names
4. Returns `{"unmapped": [...], "total_materials": int, "mapped_count": int}`
- **Response schema**:
```python
class MaterialSuggestion(BaseModel):
id: uuid.UUID
name: str
schaeffler_code: str
class UnmappedMaterial(BaseModel):
raw_name: str
suggestions: list[MaterialSuggestion]
class UnmappedMaterialCheck(BaseModel):
unmapped: list[UnmappedMaterial]
total_materials: int
mapped_count: int
```
- **Auth**: `get_current_user` (any authenticated user can check)
- **Acceptance gate**: Returns correct unmapped materials for an order with mixed mapped/unmapped materials
- **Dependencies**: Task 1
- **Risk**: None
### [x] Task 3: Backend — `POST /materials/batch-aliases` endpoint
- **File**: `backend/app/api/routers/materials.py`
- **What**: Add batch alias creation endpoint:
1. Accepts `{"mappings": [{"alias": str, "material_id": uuid}]}`
2. For each mapping, creates a `MaterialAlias` record (skips if alias already exists, case-insensitive)
3. Returns `{"created": int, "skipped": int}`
- **Auth**: `require_admin_or_pm` — only admins/PMs should be able to create aliases
- **Validation**: Verify each `material_id` exists; reject if alias is empty; skip duplicate aliases
- **Acceptance gate**: Batch creates multiple aliases in one request; subsequent `resolve_material_map()` calls resolve through new aliases
- **Dependencies**: None
- **Risk**: None
### [x] Task 4: Frontend — API functions for material checking and batch alias creation
- **File**: `frontend/src/api/materials.ts`
- **What**: Add TypeScript interfaces and API functions:
```typescript
export interface MaterialSuggestion {
id: string
name: string
schaeffler_code: string
}
export interface UnmappedMaterial {
raw_name: string
suggestions: MaterialSuggestion[]
}
export interface UnmappedMaterialCheck {
unmapped: UnmappedMaterial[]
total_materials: number
mapped_count: number
}
export async function checkOrderMaterials(orderId: string): Promise<UnmappedMaterialCheck>
export async function batchCreateAliases(mappings: Array<{ alias: string; material_id: string }>): Promise<{ created: number; skipped: number }>
```
- **Acceptance gate**: Types compile; functions callable from components
- **Dependencies**: Tasks 2, 3
- **Risk**: None
### [x] Task 5: Frontend — `UnmappedMaterialsDialog` component
- **File**: `frontend/src/components/orders/UnmappedMaterialsDialog.tsx`
- **What**: Create a modal dialog component:
1. **Props**: `open: boolean`, `unmapped: UnmappedMaterial[]`, `onResolved: () => void`, `onCancel: () => void`
2. **UI**: Fixed overlay (same pattern as existing modals), wider (`max-w-xl`):
- Warning icon (`AlertTriangle` from lucide-react) + title "Unmapped Materials"
- Subtitle: "The following materials have no alias to a library material. Map them before rendering."
- For each unmapped material: row with raw name on left, `<select>` dropdown on right
- Dropdown pre-populated with `suggestions` from API, plus option to browse all library materials
3. **State**: `mappings: Record<string, string>` — raw_name → selected material_id
4. **"Map All & Proceed" button**: disabled until all unmapped materials have a selection; on click calls `batchCreateAliases()`, then `onResolved()` (triggers dispatch)
5. **Cancel button**: calls `onCancel()`, does not dispatch
- **Styling**: Follow existing modal patterns, Tailwind classes, lucide-react icons
- **Acceptance gate**: Dialog renders, dropdowns work, batch alias creation succeeds, dialog closes and render dispatches
- **Dependencies**: Task 4
- **Risk**: Low — need to match existing modal patterns
### [x] Task 6: Frontend — Intercept "Dispatch Renders" in `OrderDetail.tsx`
- **File**: `frontend/src/pages/OrderDetail.tsx`
- **What**:
1. Add state for material check data and dialog visibility
2. Replace direct `dispatchMut.mutate()` on "Dispatch Renders" button with `handleDispatch()`:
- Calls `checkOrderMaterials(id)`
- If `unmapped.length > 0`: show `UnmappedMaterialsDialog`
- If `unmapped.length === 0`: proceed with `dispatchMut.mutate()`
3. `onResolved` callback: close dialog, call `dispatchMut.mutate()`
4. Import and render `<UnmappedMaterialsDialog>` conditionally
- **Acceptance gate**: Dispatch with unmapped materials shows dialog; mapping all and clicking proceed dispatches; dispatch with all mapped skips dialog
- **Dependencies**: Task 5
- **Risk**: Low
### [ ] Task A2: Create `useGeometryMerge` hook
- **File**: NEW `frontend/src/components/cad/useGeometryMerge.ts`
- **What**: Hook that takes `meshRegistryRef`, `partMaterials`, `pbrMap`, and `enabled` flag. When enabled:
1. Groups meshes by material key (via `groupRegistryByMaterial`)
2. For each group: calls `BufferGeometryUtils.mergeGeometries()` on all mesh geometries (with world transforms applied via `mesh.matrixWorld`)
3. Creates one new `THREE.Mesh` per group with the shared material
4. Hides original meshes (`visible = false`)
5. Adds merged meshes to the scene
6. Returns `{ mergedGroups: MergedGroup[], restore: () => void }``restore()` removes merged meshes, re-shows originals
### [x] Task 7: Frontend — Warning badges on Materials page
When disabled (or on cleanup): calls `restore()`.
Important: must handle `BufferGeometryUtils` import from `three/examples/jsm/utils/BufferGeometryUtils.js`.
- **Acceptance gate**: TypeScript compiles. Hook can be called with `enabled=false` without errors.
- **Dependencies**: Task A1
- **Risk**: Medium — `mergeGeometries` requires all geometries to have same attribute layout (position, normal, uv). Some meshes may lack UVs. Must filter or skip incompatible groups.
### [ ] Task A3: Integrate Performance mode in ThreeDViewer
- **File**: `frontend/src/components/cad/ThreeDViewer.tsx`
- **File**: `frontend/src/pages/Materials.tsx`
- **What**:
1. Add `perfMode` state (boolean, default false)
2. Add toolbar button (after wireframe toggle, ~line 771): `<TBtn active={perfMode} onClick={() => setPerfMode(p => !p)} title="Performance mode — merges geometries, disables per-part hover">` with `Layers` icon from lucide-react
3. Call `useGeometryMerge({ meshRegistryRef, partMaterials: effectiveMaterials, pbrMap, enabled: perfMode, sceneRef })`
4. When `perfMode` is true: disable hover handlers (set `onPointerOver`/`onPointerOut`/`onClick` to undefined on the `<primitive>` element), hide MaterialPanel part list
5. When `perfMode` is false: restore normal interaction
6. Show draw call count in toolbar badge: `renderer.info.render.calls` (read from `gl` via `useThree`)
- **Acceptance gate**: Toggle Performance mode → `renderer.info.render.calls` drops to < 20 for 100-part assembly. Toggle back → all hover/select/material interactions work.
- **Dependencies**: Task A2
- **Risk**: Medium — must ensure merged meshes inherit correct material properties (PBR). Must not break camera fitting (merged meshes have different bounding boxes).
### [ ] Task A4: Integrate Performance mode in InlineCadViewer
- **File**: `frontend/src/components/cad/InlineCadViewer.tsx`
- **What**: Same as Task A3 but for the inline viewer. Add `perfMode` toggle button to toolbar (~line 455). Integrate `useGeometryMerge` hook. Disable hover when in perf mode.
- **Acceptance gate**: Same as A3 — draw calls drop, interactions restored on toggle-off.
- **Dependencies**: Task A2
- **Risk**: Low — same pattern as A3
---
### Track B — Merge Dual STEP Parse
### [ ] Task B1: Create `extract_step_metadata()` unified function
- **File**: `backend/app/services/step_processor.py`
- **What**: New function (insert after line 389, before `_extract_step_objects`):
```python
@dataclass
class StepMetadata:
objects: list[str] # part names from XCAF labels
edge_data: dict # sharp_edge_pairs, suggested_smooth_angle, etc.
dimensions_mm: dict | None # bbox dimensions
bbox_center_mm: dict | None
def extract_step_metadata(step_path: str) -> StepMetadata:
```
Implementation approach:
1. Read STEP once with `STEPCAFControl_Reader` (same as `_extract_step_objects`)
2. Extract part names from XCAF labels (same logic as current `_extract_step_objects`)
3. Get root shape via `shape_tool.GetShape(label)` for each free label
4. Tessellate at 0.5mm deflection via `BRepMesh_IncrementalMesh`
5. Extract edge topology from the tessellated shape (same logic as current `extract_mesh_edge_data` lines 265382, but operating on the already-loaded shape instead of re-reading)
6. Extract bbox from the same shape
7. Return `StepMetadata` dataclass
Must handle both `OCC.Core` (pythonocc) and `OCP` (cadquery) import paths, same as existing code.
**Keep `_extract_step_objects` and `extract_mesh_edge_data` unchanged** as fallbacks.
- **Acceptance gate**: `python3 -c "import ast; ast.parse(open('backend/app/services/step_processor.py').read())"` passes. New function returns same data as the two separate calls combined.
- **Dependencies**: none
- **Risk**: Medium — the edge extraction logic references `STEPControl_Reader`-specific APIs (`reader.TransferRoots()`, `reader.OneShape()`). With `STEPCAFControl_Reader`, the shape comes from `shape_tool.GetShape(label)` instead. The edge extraction code uses `TopTools_IndexedDataMapOfShapeListOfShape` on the root shape — this should work identically on an XCAF-sourced shape since it's the same `TopoDS_Shape` underneath. Must verify the `_using_ocp` vs `OCC.Core` static method dispatch (`_s` suffix) still works.
### [ ] Task B2: Wire `extract_step_metadata()` into `extract_cad_metadata()`
- **File**: `backend/app/services/step_processor.py`
- **What**: Modify `extract_cad_metadata()` (line 82) to:
1. Try `extract_step_metadata()` first (single read)
2. If it succeeds: use `metadata.objects` for `parsed_objects`, `metadata.edge_data` for `mesh_attributes`
3. If it fails (fallback): call `_extract_step_objects()` + `extract_mesh_edge_data()` separately (existing behavior)
4. Log which path was taken: `"[STEP] unified read: X objects, Y sharp pairs"` vs `"[STEP] fallback: separate reads"`
- **Acceptance gate**: Upload a STEP file → worker log shows single "unified read" message. `parsed_objects` and `mesh_attributes` populated correctly.
- **Dependencies**: Task B1
- **Risk**: Low — fallback preserves existing behavior
### [ ] Task B3: Also wire into `process_cad_file()` (legacy path)
- **File**: `backend/app/services/step_processor.py`
- **What**: Same change as B2 but for `process_cad_file()` (line 137) which is the legacy full-pipeline function. Try unified read first, fall back to separate reads.
- **Acceptance gate**: `process_cad_file()` still works end-to-end (upload STEP → metadata + thumbnail).
- **Dependencies**: Task B1
1. In the "Custom" materials section, show a warning badge (`AlertTriangle` icon + "No alias") for materials that have no `schaeffler_code` AND no aliases
2. Add a "Map to Library" action button that opens a dropdown to quickly assign a SCHAEFFLER material as alias
3. Optional: "Show unmapped only" filter toggle
- **Acceptance gate**: Custom materials without aliases show a warning; mapping removes it
- **Dependencies**: None (independent)
- **Risk**: Low
## Migration Check
No migration required. All changes are code-level optimizations.
**No** — no new database columns needed. `MaterialAlias` model already exists. All changes use existing tables.
## Order Recommendation
Track A and Track B are fully independent — implement in parallel.
Within Track A: A1 → A2 → A3 + A4 (A3 and A4 can be parallel)
Within Track B: B1 → B2 + B3 (B2 and B3 can be parallel)
1. Backend service function (Task 1)
2. Backend endpoints (Tasks 2-3, parallel)
3. Frontend API types (Task 4)
4. Frontend dialog + integration (Tasks 5-6)
5. Materials page badges (Task 7, independent)
## Risks / Open Questions
1. **BufferGeometryUtils.mergeGeometries compatibility**: All geometries in a merge group must have identical attribute sets (position, normal, uv). Meshes without UVs can't merge with UV-bearing meshes. The hook must detect this and skip incompatible groups (leave them as individual meshes).
1. **Performance**: `find_unmapped_materials()` loads all aliases and library materials per check. For the current scale (~50 materials, ~100 aliases) this is fine. If scale grows, add caching.
2. **Camera fitting in Performance mode**: `CameraFit` component likely uses scene bounding box. Merged meshes may have different world-space bounds than originals if transforms aren't baked correctly. Must apply `mesh.matrixWorld` to geometry before merging.
2. **Material name normalization**: Should the check be case-insensitive? Yes — alias lookup is already case-insensitive, so the check should match.
3. **OCC.Core API differences**: pythonocc (`OCC.Core`) uses different method naming than OCP (no `_s` suffix for static methods). The unified function must handle both, same as `extract_mesh_edge_data` currently does.
4. **Edge extraction on XCAF shape**: `extract_mesh_edge_data` calls `reader.OneShape()` which returns a single compound. From XCAF, `shape_tool.GetShape(label)` returns the shape for each free label. For multi-root STEP files (rare), we need to iterate all free labels and combine edge data. This matches the pattern already used in `export_step_to_gltf.py` (line 696700).
5. **Memory**: `mergeGeometries` creates new geometry buffers. For 100 parts × 50K triangles each = 5M triangles in merged buffers + 5M in originals (hidden but not disposed). May need to dispose original geometries in Performance mode and recreate on restore. This adds complexity — defer disposal to a follow-up if memory isn't an issue.
3. **Products without cad_part_materials**: Some products may not have materials assigned yet (no STEP file processed). These are skipped — the check only validates materials that exist.
+12
View File
@@ -63,6 +63,16 @@ def parse_args() -> SimpleNamespace:
_usd_idx = sys.argv.index("--usd-path")
usd_path = sys.argv[_usd_idx + 1] if _usd_idx + 1 < len(sys.argv) else ""
focal_length_mm = None
if "--focal-length" in sys.argv:
_fl_idx = sys.argv.index("--focal-length")
focal_length_mm = float(sys.argv[_fl_idx + 1]) if _fl_idx + 1 < len(sys.argv) else None
sensor_width_mm_override = None
if "--sensor-width" in sys.argv:
_sw_idx = sys.argv.index("--sensor-width")
sensor_width_mm_override = float(sys.argv[_sw_idx + 1]) if _sw_idx + 1 < len(sys.argv) else None
if template_path and not os.path.isfile(template_path):
print(f"[blender_render] ERROR: template not found: {template_path}")
sys.exit(1)
@@ -96,4 +106,6 @@ def parse_args() -> SimpleNamespace:
mesh_attributes=mesh_attributes,
usd_path=usd_path,
use_template=bool(template_path),
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm_override,
)
+14 -6
View File
@@ -10,7 +10,9 @@ SENSOR_WIDTH_MM = 36.0
FILL_FACTOR = 0.85
def setup_auto_camera(parts: list, width: int, height: int):
def setup_auto_camera(parts: list, width: int, height: int,
lens_mm: float | None = None,
sensor_width_mm: float | None = None):
"""Compute bounding sphere and place an isometric auto-camera.
Returns (bbox_center, bsphere_radius) as a tuple so the caller can
@@ -19,6 +21,9 @@ def setup_auto_camera(parts: list, width: int, height: int):
import bpy # type: ignore[import]
from mathutils import Vector, Matrix # type: ignore[import]
_lens = lens_mm if lens_mm is not None else LENS_MM
_sensor = sensor_width_mm if sensor_width_mm is not None else SENSOR_WIDTH_MM
all_corners = []
for part in parts:
all_corners.extend(part.matrix_world @ Vector(c) for c in part.bound_box)
@@ -50,18 +55,21 @@ def setup_auto_camera(parts: list, width: int, height: int):
math.sin(elevation_rad),
)).normalized()
fov_h = math.atan(SENSOR_WIDTH_MM / (2.0 * LENS_MM))
fov_v = math.atan(SENSOR_WIDTH_MM * (height / width) / (2.0 * LENS_MM))
fov_h = math.atan(_sensor / (2.0 * _lens))
fov_v = math.atan(_sensor * (height / width) / (2.0 * _lens))
fov_used = min(fov_h, fov_v)
dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
dist = max(dist, bsphere_radius * 1.5)
print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°")
# Minimum distance: prevent camera from being inside the bounding sphere,
# but scale with FOV so wide-angle lenses can still frame correctly.
min_dist = bsphere_radius * 1.05 # just outside the sphere surface
dist = max(dist, min_dist)
print(f"[blender_render] camera dist={dist:.4f}, fov={math.degrees(fov_used):.2f}°, lens={_lens}mm")
cam_location = bbox_center + cam_dir * dist
bpy.ops.object.camera_add(location=cam_location)
cam_obj = bpy.context.active_object
cam_obj.data.lens = LENS_MM
cam_obj.data.lens = _lens
bpy.context.scene.camera = cam_obj
look_dir = (bbox_center - cam_location).normalized()
+48 -7
View File
@@ -8,12 +8,45 @@ import time as _time
FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial"
def _find_material_with_nodes(base_name: str):
"""Find a material by name that actually has shader nodes.
Blender's USD importer creates empty stub materials (use_nodes=True,
node_tree has 0 nodes) from USD material bindings. When we later
append the real material from a .blend library, Blender renames it
with a .001/.002 suffix to avoid the name collision.
This helper searches bpy.data.materials for the version that has
actual shader nodes, preferring exact name match, then .NNN suffixes.
"""
import bpy # type: ignore[import]
# Exact name first
exact = bpy.data.materials.get(base_name)
if exact and exact.node_tree and len(exact.node_tree.nodes) > 0:
return exact
# Search for .NNN suffixed versions
for mat in bpy.data.materials:
if not mat.name.startswith(base_name):
continue
suffix = mat.name[len(base_name):]
if suffix == "" or _re.match(r'^\.\d{3}$', suffix):
if mat.node_tree and len(mat.node_tree.nodes) > 0:
return mat
return None
def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
"""Append multiple materials from a .blend file in a single open.
Uses bpy.data.libraries.load() to open the .blend once instead of
N separate bpy.ops.wm.append() calls (each reopens the file).
Falls back to individual append for any materials that fail to load.
Handles empty material stubs left by Blender's USD importer: when a
stub exists with the target name, the library material gets renamed
with a .NNN suffix. We find it via _find_material_with_nodes().
"""
import bpy # type: ignore[import]
@@ -28,12 +61,16 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
to_load = [n for n in names if n in available]
not_found = names - available
data_to.materials = to_load
# After the context manager closes, materials are loaded into bpy.data
# After the context manager closes, materials are loaded into bpy.data.
# If a USD stub occupied the name, the real material gets a .NNN suffix.
for mat_name in to_load:
mat = bpy.data.materials.get(mat_name)
mat = _find_material_with_nodes(mat_name)
if mat:
result[mat_name] = mat
print(f"[blender_render] batch-appended material: {mat_name}")
if mat.name != mat_name:
print(f"[blender_render] batch-appended material: {mat_name} (as '{mat.name}', stub collision)")
else:
print(f"[blender_render] batch-appended material: {mat_name}")
else:
print(f"[blender_render] WARNING: material '{mat_name}' not found after batch append")
if not_found:
@@ -51,7 +88,7 @@ def _batch_append_materials(mat_lib_path: str, names: set[str]) -> dict:
filename=mat_name,
link=False,
)
mat = bpy.data.materials.get(mat_name)
mat = _find_material_with_nodes(mat_name)
if mat:
result[mat_name] = mat
except Exception:
@@ -141,11 +178,15 @@ def apply_material_library_direct(
# Batch-append materials from library (single file open)
appended: dict = {}
_t_append = _time.monotonic()
# Check already-loaded materials first
# Check already-loaded materials first — but skip empty stubs created by
# Blender's USD importer (use_nodes=True but node_tree has 0 nodes).
# Those stubs must be loaded from the library via _batch_append_materials
# which uses _find_material_with_nodes() to resolve stub collisions.
still_needed = set()
for mat_name in needed:
if mat_name in bpy.data.materials:
appended[mat_name] = bpy.data.materials[mat_name]
existing = _find_material_with_nodes(mat_name)
if existing:
appended[mat_name] = existing
else:
still_needed.add(mat_name)
# Load remaining from .blend in one pass
@@ -111,7 +111,9 @@ def _setup_mode_b(args, lap_fn: Callable[[str], None]) -> None:
print(f"[blender_render] template mode: {len(parts)} parts imported into collection '{args.target_collection}'")
if needs_auto_camera:
setup_auto_camera(parts, args.width, args.height)
setup_auto_camera(parts, args.width, args.height,
lens_mm=args.focal_length_mm,
sensor_width_mm=args.sensor_width_mm)
def _setup_mode_a(args) -> None:
@@ -156,7 +158,9 @@ def _setup_mode_a(args) -> None:
build_mat_map_lower(args.material_map), args.part_names_ordered,
)
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height)
bbox_center, bsphere_radius = setup_auto_camera(parts, args.width, args.height,
lens_mm=args.focal_length_mm,
sensor_width_mm=args.sensor_width_mm)
setup_auto_lights(bbox_center, bsphere_radius)
world = bpy.data.worlds.new("World")
bpy.context.scene.world = world
+4 -52
View File
@@ -497,56 +497,6 @@ def _collect_part_key_map(shape_tool, free_labels) -> dict:
return part_key_map
def _apply_glb_mm_to_m_scale(glb_path: Path) -> None:
"""Wrap all GLB scene root nodes under a new root node with scale 0.001.
RWGltf_CafWriter exports geometry in mm (original STEP units).
BRepBuilderAPI_Transform destroys Poly_Triangulation, so we cannot scale
the B-Rep before export. Instead we add a root transform node to the GLB
that scales mm m. glTF spec uses metres; Three.js and Blender honour
node scale transforms.
The GLB binary is re-serialized in-place.
"""
import struct as _struct
data = glb_path.read_bytes()
json_len = _struct.unpack_from("<I", data, 12)[0]
json_type = _struct.unpack_from("<I", data, 16)[0]
if json_type != 0x4E4F534A: # "JSON"
return
j = json.loads(data[20: 20 + json_len])
if "scenes" not in j or not j["scenes"]:
return
scene = j["scenes"][0]
old_roots = scene.get("nodes", [])
if not old_roots:
return
# Create a new root node with mm→m scale
nodes = j.setdefault("nodes", [])
new_root_idx = len(nodes)
nodes.append({
"name": "__mm_to_m_root__",
"scale": [0.001, 0.001, 0.001],
"children": old_roots,
})
scene["nodes"] = [new_root_idx]
new_json = json.dumps(j, separators=(",", ":"))
pad = (4 - len(new_json) % 4) % 4
new_json_bytes = new_json.encode() + b" " * pad
rest = data[20 + json_len:] # BIN chunk and anything after
new_chunk = _struct.pack("<II", len(new_json_bytes), 0x4E4F534A) + new_json_bytes
new_total = 12 + len(new_chunk) + len(rest)
new_header = _struct.pack("<III", 0x46546C67, 2, new_total)
glb_path.write_bytes(new_header + new_chunk + rest)
def _inject_glb_extras(glb_path: Path, extras: dict, part_key_map: dict | None = None) -> None:
"""Patch a GLB binary to add/update scenes[0].extras JSON field.
@@ -817,8 +767,10 @@ def main() -> None:
except Exception as _exc:
print(f"WARNING: GLB extras injection failed (non-fatal): {_exc}", file=sys.stderr)
# NOTE: RWGltf_CafWriter already converts mm → m and Z-up → Y-up internally.
# No additional scaling or coordinate transform is needed.
# NOTE: RWGltf_CafWriter reads unit metadata from the XDE document (set by
# STEPCAFControl_Reader from the STEP file's SI_UNIT declarations) and converts
# mm → m automatically. It also handles Z-up → Y-up coordinate transform.
# No additional scaling or BRepBuilderAPI_Transform is needed.
try:
+15 -3
View File
@@ -373,6 +373,18 @@ def main():
except Exception:
pass
# Named argument: --focal-length <mm>
_focal_length = None
if "--focal-length" in argv:
_idx = argv.index("--focal-length")
_focal_length = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
# Named argument: --sensor-width <mm>
_sensor_width = None
if "--sensor-width" in argv:
_idx = argv.index("--sensor-width")
_sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
os.makedirs(os.path.dirname(output_path), exist_ok=True)
try:
@@ -616,8 +628,8 @@ def main():
# ── Camera (isometric-style, matches blender_render.py) ──────────────
ELEVATION_DEG = 28.0
AZIMUTH_DEG = 40.0
LENS_MM = 50.0
SENSOR_WIDTH_MM = 36.0
LENS_MM = _focal_length if _focal_length is not None else 50.0
SENSOR_WIDTH_MM = _sensor_width if _sensor_width is not None else 36.0
FILL_FACTOR = 0.85
elevation_rad = math.radians(ELEVATION_DEG)
@@ -634,7 +646,7 @@ def main():
fov_used = min(fov_h, fov_v)
dist = (bsphere_radius / math.tan(fov_used)) / FILL_FACTOR
dist = max(dist, bsphere_radius * 1.5)
dist = max(dist, bsphere_radius * 1.05)
cam_location = bbox_center + cam_dir * dist
bpy.ops.object.camera_add(location=cam_location)
+170 -57
View File
@@ -342,18 +342,45 @@ def main():
except Exception:
pass
# Named argument: --camera-orbit — rotate camera around model instead of rotating model
camera_orbit = "--camera-orbit" in argv
# Named argument: --usd-path <path> — when set, import USD instead of GLB
usd_path = ""
if "--usd-path" in argv:
_usd_idx = argv.index("--usd-path")
usd_path = argv[_usd_idx + 1] if _usd_idx + 1 < len(argv) else ""
# Pre-load USD import helper once (used in both MODE A and MODE B)
# Named argument: --focal-length <mm>
_focal_length = None
if "--focal-length" in argv:
_idx = argv.index("--focal-length")
_focal_length = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
# Named argument: --sensor-width <mm>
_sensor_width = None
if "--sensor-width" in argv:
_idx = argv.index("--sensor-width")
_sensor_width = float(argv[_idx + 1]) if _idx + 1 < len(argv) else None
# Ensure scripts dir is on path for shared module imports
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
# Pre-load USD import helper (used in both MODE A and MODE B)
_import_usd_file = None
if usd_path:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from import_usd import import_usd_file as _import_usd_file # type: ignore[assignment]
# Shared material helpers (handle USD stub collisions correctly)
from _blender_materials import (
apply_material_library_direct as _apply_material_library_direct,
apply_material_library as _apply_material_library_shared,
build_mat_map_lower as _build_mat_map_lower,
assign_failed_material as _assign_failed_material,
)
os.makedirs(frames_dir, exist_ok=True)
try:
@@ -390,6 +417,7 @@ def main():
print(f"[turntable_render] material_library={material_library_path}, material_map keys={list(material_map.keys())}")
# ── SCENE SETUP ──────────────────────────────────────────────────────────
_usd_mat_lookup: dict = {} # populated by import_usd_file when USD path is used
if use_template:
# ── MODE B: Template-based render ────────────────────────────────────
@@ -401,7 +429,7 @@ def main():
# Import geometry: USD path when available, otherwise GLB
if usd_path and _import_usd_file:
parts = _import_usd_file(usd_path)
parts, _usd_mat_lookup = _import_usd_file(usd_path)
else:
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
@@ -419,20 +447,30 @@ def main():
for part in parts:
_apply_smooth(part, SMOOTH_ANGLE)
# Material assignment: library materials if available, otherwise palette
if material_library_path and material_map:
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
# Parts not matched by library get palette fallback
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
else:
for i, part in enumerate(parts):
step_name = _resolve_part_name(i, part, part_names_ordered)
color_hex = part_colors.get(step_name)
if not color_hex:
_assign_palette_material(part, i)
# Material assignment: USD primvar path first, then name-matching fallback
if material_library_path and _usd_mat_lookup:
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
# Fall back to name-matching for parts without USD primvars
if material_map:
_unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
if _unassigned:
print(f"[turntable_render] {len(_unassigned)} parts without USD primvar — "
f"falling back to name-matching", flush=True)
_apply_material_library_shared(
_unassigned, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
elif material_library_path and material_map:
_apply_material_library_shared(
parts, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
# Palette fallback for any parts still without materials
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
# ── Shadow catcher (Cycles only, template mode only) ─────────────────
if shadow_catcher:
@@ -482,7 +520,7 @@ def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
if usd_path and _import_usd_file:
parts = _import_usd_file(usd_path)
parts, _usd_mat_lookup = _import_usd_file(usd_path)
else:
parts = _import_glb(glb_path)
# Apply render position rotation before material/camera setup
@@ -493,14 +531,23 @@ def main():
for i, part in enumerate(parts):
_apply_smooth(part, SMOOTH_ANGLE)
# Material assignment: library materials if available, else part_colors/palette
if material_library_path and material_map:
mat_map_lower = {k.lower(): v for k, v in material_map.items()}
_apply_material_library(parts, material_library_path, mat_map_lower, part_names_ordered)
# Palette fallback for unmatched parts
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
# Material assignment: USD primvar path first, then name-matching fallback
if material_library_path and _usd_mat_lookup:
_apply_material_library_direct(parts, material_library_path, _usd_mat_lookup)
if material_map:
_unassigned = [p for p in parts if not p.data.materials or
(len(p.data.materials) == 1 and p.data.materials[0] and
p.data.materials[0].name == "SCHAEFFLER_059999_FailedMaterial")]
if _unassigned:
_apply_material_library_shared(
_unassigned, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
elif material_library_path and material_map:
_apply_material_library_shared(
parts, material_library_path,
_build_mat_map_lower(material_map), part_names_ordered,
)
else:
# part_colors or palette — use index-based lookup via part_names_ordered
for i, part in enumerate(parts):
@@ -523,6 +570,10 @@ def main():
part.data.materials.append(mat)
else:
_assign_palette_material(part, i)
# Palette fallback for any parts still without materials
for i, part in enumerate(parts):
if not part.data.materials or len(part.data.materials) == 0:
_assign_palette_material(part, i)
if needs_auto_camera:
# ── Combined bounding box / bounding sphere ──────────────────────────
@@ -573,7 +624,19 @@ def main():
fill.data.size = max(4.0, bsphere_radius * 4.0)
# ── Camera ───────────────────────────────────────────────────────────
cam_dist = bsphere_radius * 2.5
_lens = _focal_length if _focal_length is not None else 50.0
_sw = _sensor_width if _sensor_width is not None else 36.0
if _focal_length is not None:
# FOV-based distance when focal length is explicitly set
_fov_h = math.atan(_sw / (2.0 * _lens))
_fov_v = math.atan(_sw * (height / width) / (2.0 * _lens))
_fov_used = min(_fov_h, _fov_v)
_FILL_FACTOR = 0.85
cam_dist = (bsphere_radius / math.tan(_fov_used)) / _FILL_FACTOR
cam_dist = max(cam_dist, bsphere_radius * 1.05)
print(f"[turntable_render] FOV-based cam_dist={cam_dist:.4f}, lens={_lens}mm")
else:
cam_dist = bsphere_radius * 2.5
cam_location = Vector((
bbox_center.x + cam_dist,
bbox_center.y,
@@ -582,6 +645,7 @@ def main():
bpy.ops.object.camera_add(location=cam_location)
camera = bpy.context.active_object
bpy.context.scene.camera = camera
camera.data.lens = _lens
camera.data.clip_start = max(cam_dist * 0.001, 0.0001)
camera.data.clip_end = cam_dist * 10.0
@@ -606,30 +670,57 @@ def main():
bg.inputs["Color"].default_value = (0.96, 0.96, 0.97, 1.0)
bg.inputs["Strength"].default_value = 0.15
# ── Turntable pivot ──────────────────────────────────────────────────
pivot = bpy.data.objects.new("pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
# Parent camera to pivot
camera.parent = pivot
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
# Keyframe pivot rotation
# ── Turntable animation ──────────────────────────────────────────────
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
if camera_orbit:
# Camera orbit: parent camera to pivot, rotate pivot
pivot = bpy.data.objects.new("pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
camera.parent = pivot
camera.location = (cam_dist, 0, bsphere_radius * 0.5)
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
_set_fcurves_linear(pivot.animation_data.action)
print(f"[turntable] camera_orbit=True — rotating camera around model")
else:
# Object rotation: camera stays fixed, model parts rotate around bbox center
pivot = bpy.data.objects.new("turntable_pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
bpy.context.view_layer.update()
# Reparent parts to pivot while preserving world positions.
# Parts may have existing USD parents (Xform nodes), so simple
# matrix_parent_inverse = pivot.inverted() is NOT enough — it
# loses the old parent's contribution. Instead, capture world
# matrix, reparent, then restore world position via local matrix.
for part in parts:
mw = part.matrix_world.copy()
part.parent = pivot
part.matrix_parent_inverse.identity()
bpy.context.view_layer.update()
part.matrix_local = pivot.matrix_world.inverted() @ mw
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
_set_fcurves_linear(pivot.animation_data.action)
print(f"[turntable] camera_orbit=False — rotating model in front of camera")
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
_set_fcurves_linear(pivot.animation_data.action)
else:
# Template has camera — set up turntable on the model parts instead
# Template has its own camera (not auto-camera)
scene = bpy.context.scene
scene.frame_start = 1
scene.frame_end = frame_count
@@ -645,22 +736,44 @@ def main():
(min(v.z for v in all_corners) + max(v.z for v in all_corners)) * 0.5,
))
# Create a pivot empty and parent all parts to it
pivot = bpy.data.objects.new("turntable_pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
if camera_orbit:
# Camera orbit mode: rotate the template's camera around the model
template_cam = scene.camera
pivot = bpy.data.objects.new("turntable_pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
for part in parts:
part.parent = pivot
template_cam.parent = pivot
# Keyframe pivot rotation
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
# Linear interpolation — frame N+1 is never rendered, giving N uniform steps
_set_fcurves_linear(pivot.animation_data.action)
_set_fcurves_linear(pivot.animation_data.action)
print(f"[turntable] camera_orbit=True — rotating template camera around model")
else:
# Object rotation mode: rotate the model in front of template camera
pivot = bpy.data.objects.new("turntable_pivot", None)
bpy.context.collection.objects.link(pivot)
pivot.location = bbox_center
bpy.context.view_layer.update()
# Reparent preserving world positions (parts may have USD parents)
for part in parts:
mw = part.matrix_world.copy()
part.parent = pivot
part.matrix_parent_inverse.identity()
bpy.context.view_layer.update()
part.matrix_local = pivot.matrix_world.inverted() @ mw
pivot.rotation_euler = (0, 0, 0)
pivot.keyframe_insert(data_path="rotation_euler", frame=1)
pivot.rotation_euler = _axis_rotation(turntable_axis, degrees)
pivot.keyframe_insert(data_path="rotation_euler", frame=frame_count + 1)
_set_fcurves_linear(pivot.animation_data.action)
print(f"[turntable] camera_orbit=False — rotating model in front of template camera")
# ── Colour management ────────────────────────────────────────────────────
# In template mode the .blend file owns its colour management settings.
+45 -43
View File
@@ -1,59 +1,61 @@
# Review Report: Draw Call Batching + Merge Dual STEP Parse
Date: 2026-03-13
# Review Report: Material Alias Completeness with Blocking Dialog
Date: 2026-03-14
## Result: ⚠️ Minor issues
## Problems Found
### [backend/app/services/step_processor.py:405] `dataclass` import at module middle
**Severity**: Low
**Recommendation**: Move `from dataclasses import dataclass, field` to the top of the file with other stdlib imports. Mid-file imports work but violate PEP 8.
### [frontend/src/components/cad/useGeometryMerge.ts:6] Unused import `forEachMeshMaterial`
**Severity**: Low
**Recommendation**: Remove `forEachMeshMaterial` from the import — it's imported but never called.
### [frontend/src/components/cad/useGeometryMerge.ts:148] `partMaterials` and `pbrMap` in useEffect deps
**Severity**: Medium
**Recommendation**: `partMaterials` and `pbrMap` are objects that get new references on re-render. The `stateRef.current` early return (line 55) mitigates unnecessary re-merge, BUT the cleanup function (line 141) runs on every deps change, setting `stateRef.current = null` and triggering a full re-merge next render. Consider comparing by stable serialization or using refs. **Not blocking** because `partMaterials` only changes on user save, and the re-merge is fast for typical assemblies.
### [backend/app/services/step_processor.py:487] `ReadFile` status unchecked
**Severity**: Low
**Recommendation**: `status` from `reader.ReadFile()` is captured but never validated. Not a regression — same pattern exists at lines 270 and 650 in existing code.
## Result: ✅ Approved
## Checklist Results
### Backend / Python
- [x] No new endpoints — no role check needed
- [x] No SQL injections
- [x] Async consistency: `extract_step_metadata` is sync (called from sync Celery tasks)
- [x] Uses `logger` (not `print()`)
- [x] New endpoints have role checks: `check_materials` uses `get_current_user`, `batch_create_aliases` uses `require_admin_or_pm`
- [x] No SQL injections — all ORM usage
- [x] Pydantic input validation: `BatchAliasCreate` with `BatchAliasMapping` for POST body
- [x] Invalid IDs return 404: order not found → 404, material not found → 404
- [x] No new routers — endpoints added to existing `orders` and `materials` routers
- [x] No new models — uses existing `Material`, `MaterialAlias`
- [x] Async consistency: all handlers are `async def`
- [x] No `print()` in production code
- [x] No hardcoded paths
- [x] Fallback preserves existing behavior
- [N/A] No new models or migrations
- [x] `storage_key` not touched
### Database
- [x] No migration needed — uses existing tables
### Frontend / TypeScript
- [x] `tsc --noEmit` passes with 0 errors
- [x] No new API interfaces needed (pure client-side optimization)
- [x] No Tailwind opacity syntax violations
- [x] Icons from `lucide-react` only
- [x] New API interfaces in `frontend/src/api/materials.ts`: `MaterialSuggestion`, `UnmappedMaterial`, `UnmappedMaterialCheck`
- [x] No `as any` for API responses — correct types throughout
- [x] CSS vars use inline style where needed (`style={{ backgroundColor: 'var(--color-bg-surface)' }}`)
- [x] Loading states: `checkingMaterials` for button, `saving` in dialog, `quickMapMut.isPending`
- [x] Error feedback: error state in dialog, toast on quick-map success/failure
- [x] No new role-dependent UI elements (dispatch button already gated by `canDispatch`)
- [x] TypeScript compiles clean (`tsc --noEmit` passes)
### Render Pipeline
- [x] Material alias lookup order preserved: aliases FIRST, then exact name
- [x] `find_unmapped_materials()` follows same resolution logic as `resolve_material_map()`
### Security
- [x] No credentials in code
- [x] No hardcoded tokens or secrets
- [x] No hardcoded tokens
- [x] English variable names and comments
## Positives
## Minor Notes (non-blocking)
1. **Excellent fallback strategy**: Both callers try unified read first, fall back to separate reads on failure. Zero-risk deployment.
2. **Geometry attribute compatibility check** (useGeometryMerge.ts:75-81): Correctly compares attribute sets before merging — prevents crash on mismatched attributes.
3. **Clean restore logic**: `_restore()` properly disposes merged geometry and materials, restores visibility and raycast. No memory leaks on toggle-off.
4. **OCC.Core vs OCP dual-import handling**: `_using_ocp` flag with lambda wrappers for `_s` suffix dispatch matches existing codebase pattern.
5. **World transform baking** (useGeometryMerge.ts:71-73): Correctly applies `matrixWorld` to cloned geometry before merging.
6. **Cloned geometry disposal** (line 131): Disposes intermediate buffers after merge.
### `UnmappedMaterialsDialog.tsx:56``bg-amber-500/10` opacity syntax
Uses Tailwind opacity on `bg-amber-500/10` and `border-amber-500/30`. This is fine because `amber-500` is a static Tailwind color (not a CSS variable), so the `/opacity` syntax works correctly here. No issue.
### `UnmappedMaterialsDialog.tsx:99``.sort()` mutates in render
The `libraryMaterials.sort(...)` inside JSX mutates the filtered array on every render. Functionally harmless since `libraryMaterials` is a fresh array from `.filter()`, but a `[...libraryMaterials].sort(...)` or `useMemo` would be cleaner. Non-blocking.
### `dispatch_single_line_render` endpoint in orders.py
This endpoint (and its frontend `dispatchLineRender` function + `dispatchLineMut` usage in `OrderLineRow`) appeared in the diff but is not part of the material alias plan. It's a separate per-line dispatch feature. It looks correct: proper auth (`require_admin_or_pm`), 404 checks, status validation, same dispatch pattern as the bulk dispatch endpoint.
## Positives
- Clean separation: service function (`find_unmapped_materials`) is reusable and follows the same resolution logic as `resolve_material_map()`
- Deduplication in material name collection (`seen` set, case-insensitive)
- Graceful fallback: if `checkOrderMaterials` fails, dispatch proceeds anyway (no blocking on network errors)
- Suggestions via `SequenceMatcher` give useful context without external dependencies
- Quick-map on Materials page provides a second entry point for the same workflow
- Batch alias endpoint reuses existing `MaterialAlias` model — no schema changes needed
## Recommendation
Approved with minor cleanup. The unused import and mid-file import are trivial fixes. The useEffect deps issue is mitigated by existing guards and only affects rapid material reassignment during perfMode — acceptable for now.
Review complete. Result: ⚠️
Approved — ready to commit.