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:
+15
-4
@@ -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
@@ -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 ###
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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'>>,
|
||||
|
||||
@@ -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">∗</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>
|
||||
)
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 391–425)
|
||||
2. `extract_mesh_edge_data()` — `OCP.STEPControl.STEPControl_Reader` → tessellates, extracts edge topology + bbox (lines 200–388)
|
||||
**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.5–2s 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 265–382, 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 696–700).
|
||||
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user