refactor(P11+P12): codebase hygiene — CLAUDE.md rewrite, type safety, dead code removal
- Rewrite CLAUDE.md to match current 8-service architecture (was 11, 5 deleted) - Remove all as-any casts in OrderDetail.tsx (9 casts → 0) - Add cad_parsed_objects/cad_part_materials to OrderItem interface - Rename require_admin → require_global_admin across 6 router files (22 calls) - Remove EXPORT_GLB_PRODUCTION enum + generate_gltf_production_task (dead code) - Remove worker-thumbnail from ALLOWED_SERVICES, replace Flamenco link - Delete obsolete PLAN.md (1455 lines) and PLAN_REFACTOR.md (1174 lines) - Fix digit-only USD prim names with p_ prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
|
|
||||||
Automatisiertes Render-System für Schaeffler-Produktbilder. Kunden (intern) laden Excel-Auftragslisten hoch, das System extrahiert Produktdaten, verknüpft STEP-CAD-Dateien, rendert Thumbnails und Animationen über Blender (Cycles/EEVEE) oder Flamenco, und liefert fertige PNG/MP4-Ausgaben.
|
Automatisiertes Render-System für Schaeffler-Produktbilder. Kunden (intern) laden Excel-Auftragslisten hoch, das System extrahiert Produktdaten, verknüpft STEP-CAD-Dateien, rendert Thumbnails und Animationen über Blender (Cycles/EEVEE), und liefert fertige PNG/MP4-Ausgaben.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -10,9 +10,11 @@ Automatisiertes Render-System für Schaeffler-Produktbilder. Kunden (intern) lad
|
|||||||
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS, lucide-react
|
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS, lucide-react
|
||||||
- **Datenbank**: PostgreSQL 16
|
- **Datenbank**: PostgreSQL 16
|
||||||
- **Queue/Cache**: Redis 7 (Celery Broker + Backend)
|
- **Queue/Cache**: Redis 7 (Celery Broker + Backend)
|
||||||
- **Renderer**: Blender 5.0.1 (headless), cadquery (STEP→STL), Three.js (Playwright)
|
- **Storage**: MinIO (S3-kompatibel)
|
||||||
- **Render Farm**: Flamenco 3.8 (Manager + Worker, für Animationen)
|
- **Renderer**: Blender 5.0.1 (headless, Cycles GPU)
|
||||||
- **Deployment**: Docker Compose (11 Services)
|
- **CAD Parsing**: OCC (cadquery/OCP) für STEP-Parsing, GMSH 4.15 für Tessellierung
|
||||||
|
- **USD**: usd-core (pxr) für kanonische Szenen-Exporte
|
||||||
|
- **Deployment**: Docker Compose (8 Services)
|
||||||
|
|
||||||
## Services (docker-compose.yml)
|
## Services (docker-compose.yml)
|
||||||
|
|
||||||
@@ -20,14 +22,11 @@ Automatisiertes Render-System für Schaeffler-Produktbilder. Kunden (intern) lad
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `postgres` | 5432 | Primärdatenbank |
|
| `postgres` | 5432 | Primärdatenbank |
|
||||||
| `redis` | 6379 | Celery Broker |
|
| `redis` | 6379 | Celery Broker |
|
||||||
|
| `minio` | 9000/9001 | S3-kompatibler Object Store (MediaAssets) |
|
||||||
| `backend` | 8888 | FastAPI App (uvicorn) |
|
| `backend` | 8888 | FastAPI App (uvicorn) |
|
||||||
| `worker` | – | Celery Worker, Queue: `step_processing`, concurrency=8 |
|
| `worker` | – | Celery Worker, Queue: `step_processing`, concurrency=8 |
|
||||||
| `worker-thumbnail` | – | Celery Worker, Queue: `asset_pipeline`, **concurrency=1** |
|
| `render-worker` | – | Celery Worker, Queue: `asset_pipeline`, **concurrency=1** (Blender) |
|
||||||
| `beat` | – | Celery Beat (Scheduler) |
|
| `beat` | – | Celery Beat (Scheduler) |
|
||||||
| `blender-renderer` | 8100 | Blender HTTP-Service (STEP→PNG, STEP→STL) |
|
|
||||||
| `threejs-renderer` | 8101 | Three.js/Playwright HTTP-Service |
|
|
||||||
| `flamenco-manager` | 8080 | Flamenco Job Manager |
|
|
||||||
| `flamenco-worker` | – | Flamenco Render Worker (GPU) |
|
|
||||||
| `frontend` | 5173 | React/Vite Dev Server |
|
| `frontend` | 5173 | React/Vite Dev Server |
|
||||||
|
|
||||||
## Starten / Stoppen
|
## Starten / Stoppen
|
||||||
@@ -39,11 +38,10 @@ docker compose up -d
|
|||||||
# Logs einzelner Services
|
# Logs einzelner Services
|
||||||
docker compose logs -f backend
|
docker compose logs -f backend
|
||||||
docker compose logs -f worker
|
docker compose logs -f worker
|
||||||
docker compose logs -f worker-thumbnail
|
docker compose logs -f render-worker
|
||||||
docker compose logs -f blender-renderer
|
|
||||||
|
|
||||||
# Neubauen nach Codeänderungen (Backend/Worker)
|
# Neubauen nach Codeänderungen (Backend/Worker)
|
||||||
docker compose up -d --build backend worker worker-thumbnail
|
docker compose up -d --build backend worker render-worker beat
|
||||||
|
|
||||||
# Frontend-Änderungen: Hot-Reload aktiv, kein Rebuild nötig
|
# Frontend-Änderungen: Hot-Reload aktiv, kein Rebuild nötig
|
||||||
```
|
```
|
||||||
@@ -53,7 +51,6 @@ docker compose up -d --build backend worker worker-thumbnail
|
|||||||
- **Admin**: admin@schaeffler.com / Admin1234!
|
- **Admin**: admin@schaeffler.com / Admin1234!
|
||||||
- **Backend API**: http://localhost:8888/docs
|
- **Backend API**: http://localhost:8888/docs
|
||||||
- **Frontend**: http://localhost:5173
|
- **Frontend**: http://localhost:5173
|
||||||
- **Flamenco Manager**: http://localhost:8080
|
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
@@ -61,21 +58,30 @@ docker compose up -d --build backend worker worker-thumbnail
|
|||||||
schaefflerautomat/
|
schaefflerautomat/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/routers/ # FastAPI Router (admin, cad, orders, products, ...)
|
│ │ ├── api/routers/ # FastAPI Router (admin, cad, orders, products, ...)
|
||||||
│ │ ├── models/ # SQLAlchemy ORM-Modelle (14 Modelle)
|
│ │ ├── core/ # Middleware, pipeline_logger, process_steps, tenant_context
|
||||||
│ │ ├── schemas/ # Pydantic In/Out-Schemas
|
│ │ ├── domains/ # Domain-driven modules (orders, media, pipeline, rendering, tenants, ...)
|
||||||
│ │ ├── services/ # Business-Logik (excel_parser, step_processor, ...)
|
│ │ │ └── pipeline/tasks/ # Active Celery task implementations
|
||||||
│ │ ├── tasks/ # Celery Tasks (step_tasks.py, flamenco_tasks.py)
|
│ │ ├── models/ # SQLAlchemy ORM-Modelle
|
||||||
│ │ └── utils/ # Auth, Seeding
|
│ │ ├── services/ # Business-Logik (step_processor, render_blender, material_service, ...)
|
||||||
│ ├── alembic/versions/ # DB-Migrationen (001–026+)
|
│ │ ├── tasks/ # Compatibility shim only (step_tasks.py, 23 lines) — do NOT add logic here
|
||||||
│ └── start.sh # Entrypoint: migrate → seed → uvicorn
|
│ │ └── utils/ # Auth, Seeding
|
||||||
|
│ ├── alembic/versions/ # DB-Migrationen (001–062+)
|
||||||
|
│ └── start.sh # Entrypoint: migrate → seed → uvicorn
|
||||||
|
├── render-worker/
|
||||||
|
│ ├── scripts/ # Blender/OCC/GMSH subprocess scripts
|
||||||
|
│ │ ├── blender_render.py # Entry point (68 lines), delegates to _blender_*.py submodules
|
||||||
|
│ │ ├── export_step_to_gltf.py # OCC/GMSH STEP → GLB tessellation
|
||||||
|
│ │ ├── export_step_to_usd.py # OCC STEP → USD canonical scene
|
||||||
|
│ │ ├── export_gltf.py # Blender: materials, seams, sharp edges on GLB
|
||||||
|
│ │ ├── import_usd.py # Blender: USD import + primvar restoration
|
||||||
|
│ │ ├── still_render.py # Blender still render
|
||||||
|
│ │ └── turntable_render.py # Blender turntable animation
|
||||||
|
│ └── Dockerfile
|
||||||
├── frontend/src/
|
├── frontend/src/
|
||||||
│ ├── api/ # API-Client-Funktionen (axios-basiert)
|
│ ├── api/ # API-Client-Funktionen (axios-basiert)
|
||||||
│ ├── components/ # Wiederverwendbare UI-Komponenten
|
│ ├── components/ # Wiederverwendbare UI-Komponenten
|
||||||
│ └── pages/ # Seitenkomponenten
|
│ └── pages/ # Seitenkomponenten
|
||||||
├── blender-renderer/ # Blender HTTP-Microservice (Python Flask)
|
|
||||||
├── threejs-renderer/ # Three.js/Playwright Microservice (Python Flask)
|
|
||||||
├── flamenco/ # Flamenco Dockerfile + Job-Type-Scripts (.js)
|
|
||||||
└── docker-compose.yml
|
└── docker-compose.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,59 +111,60 @@ docker compose exec backend alembic current
|
|||||||
| Queue | Worker | Concurrency | Tasks |
|
| Queue | Worker | Concurrency | Tasks |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `step_processing` | `worker` | 8 | `process_step_file`, `render_order_line_task`, `dispatch_order_line_render` |
|
| `step_processing` | `worker` | 8 | `process_step_file`, `render_order_line_task`, `dispatch_order_line_render` |
|
||||||
| `asset_pipeline` | `worker-thumbnail` | 1 | `render_step_thumbnail`, `regenerate_thumbnail`, `generate_stl_cache` |
|
| `asset_pipeline` | `render-worker` | 1 | `render_step_thumbnail`, `regenerate_thumbnail`, `generate_gltf_geometry_task`, `generate_usd_master_task` |
|
||||||
| `ai_validation` | `worker` | 8 | Azure AI Validierung |
|
| `ai_validation` | `worker` | 8 | Azure AI Validierung |
|
||||||
|
|
||||||
**Wichtig**: `asset_pipeline` läuft mit concurrency=1, weil der blender-renderer nur 1 Request gleichzeitig verarbeiten kann. Mehr parallele Requests führen zu Timeouts.
|
**Wichtig**: `asset_pipeline` läuft mit concurrency=1, weil Blender single-threaded ist. Mehr parallele Tasks führen zu Abstürzen.
|
||||||
|
|
||||||
|
**Task-Location**: Aktive Implementierungen in `backend/app/domains/pipeline/tasks/`. `backend/app/tasks/step_tasks.py` ist ein 23-Zeilen Compatibility-Shim — dort keine Logik hinzufügen.
|
||||||
|
|
||||||
## STEP-Processing-Pipeline
|
## STEP-Processing-Pipeline
|
||||||
|
|
||||||
1. **Upload**: STEP-Datei hochladen → `CadFile`-Record erstellt → `process_step_file` Task eingereiht
|
1. **Upload**: STEP-Datei hochladen → `CadFile`-Record erstellt → `process_step_file` Task eingereiht
|
||||||
2. **Metadata** (`process_step_file` auf `step_processing`):
|
2. **Metadata** (`process_step_file` auf `step_processing`):
|
||||||
- STEP-Objekte extrahieren (cadquery, ~0.1s)
|
- STEP-Objekte extrahieren (OCC/cadquery, ~0.1s)
|
||||||
- `parsed_objects` in DB speichern
|
- `parsed_objects` in DB speichern
|
||||||
- glTF konvertieren (falls konfiguriert)
|
|
||||||
- Status: `processing` → queut `render_step_thumbnail`
|
- Status: `processing` → queut `render_step_thumbnail`
|
||||||
3. **Thumbnail** (`render_step_thumbnail` auf `asset_pipeline`):
|
3. **Thumbnail** (`render_step_thumbnail` auf `asset_pipeline`):
|
||||||
- Blender oder Three.js renderer aufrufen
|
- OCC/GMSH Tessellierung → GLB
|
||||||
- STL-Cache erstellen: `{step_stem}_low.stl`, `{step_stem}_high.stl`
|
- Blender Render → Thumbnail PNG
|
||||||
- Status: `completed` oder `failed`
|
- Status: `completed` oder `failed`
|
||||||
- Materialien auto-populated
|
- Materialien auto-populated
|
||||||
|
4. **USD Export** (`generate_usd_master_task` auf `asset_pipeline`):
|
||||||
## STL-Cache-Konvention
|
- OCC XCAF → USD mit Hierarchie, Materialien, Primvars
|
||||||
|
- Blender Cycles Render konsumiert USD direkt
|
||||||
STL-Dateien liegen **neben der STEP-Datei**:
|
5. **Still/Turntable Render** (`render_order_line_task` auf `step_processing` → dispatches to `asset_pipeline`):
|
||||||
```
|
- Konsumiert `usd_master` MediaAsset (nicht GLB)
|
||||||
uploads/{cad_file_id}/filename_low.stl
|
- Blender Cycles GPU → PNG/MP4
|
||||||
uploads/{cad_file_id}/filename_high.stl
|
|
||||||
```
|
|
||||||
Beim nächsten Render-Aufruf wird der Cache genutzt (keine Neu-Konvertierung).
|
|
||||||
|
|
||||||
## Material-Alias-System
|
## Material-Alias-System
|
||||||
|
|
||||||
- Materialien werden per STEP-Part-Name auf Schaeffler-Bibliotheksmaterialien (`SCHAEFFLER_...`) gemappt
|
- Materialien werden per STEP-Part-Name auf Schaeffler-Bibliotheksmaterialien (`SCHAEFFLER_...`) gemappt
|
||||||
- Lookup-Reihenfolge: **Alias-Tabelle zuerst**, dann exakter `Material.name`-Match, dann Pass-through
|
- Lookup-Reihenfolge: **Alias-Tabelle zuerst**, dann exakter `Material.name`-Match, dann Pass-through
|
||||||
- Alias-Seeding: Admin → "Seed Aliases" oder via `POST /api/materials/seed-aliases`
|
- Alias-Seeding: Admin → "Seed Aliases" oder via `POST /api/materials/seed-aliases`
|
||||||
- Neue Aliases direkt in DB oder über Material-Detail-UI hinzufügen
|
|
||||||
|
|
||||||
## Rollen
|
## Rollen
|
||||||
|
|
||||||
| Rolle | Berechtigungen |
|
| Rolle | Berechtigungen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `admin` | Vollzugriff, Admin-Panel, alle Einstellungen |
|
| `global_admin` | Vollzugriff, Admin-Panel, alle Einstellungen, plattformweite Operationen |
|
||||||
| `project_manager` | Aufträge, Analytics, Render-Trigger, STL-Download |
|
| `tenant_admin` | Mandant verwalten, Nutzer im eigenen Mandant |
|
||||||
|
| `project_manager` | Aufträge, Analytics, Render-Trigger |
|
||||||
| `client` | Eigene Aufträge anlegen und einsehen |
|
| `client` | Eigene Aufträge anlegen und einsehen |
|
||||||
|
|
||||||
|
**Auth Guards**: `require_global_admin` (platform-level), `require_pm_or_above` (admin/PM), `get_current_user` + manual check.
|
||||||
|
|
||||||
## Wichtige API-Endpoints
|
## Wichtige API-Endpoints
|
||||||
|
|
||||||
- `POST /api/uploads/excel` — Excel-Auftragsliste importieren
|
- `POST /api/uploads/excel` — Excel-Auftragsliste importieren
|
||||||
- `POST /api/orders/{id}/submit` — Auftrag einreichen
|
- `POST /api/orders/{id}/submit` — Auftrag einreichen
|
||||||
- `POST /api/orders/{id}/dispatch-renders` — Alle Render-Zeilen dispatchen
|
- `POST /api/orders/{id}/dispatch-renders` — Alle Render-Zeilen dispatchen
|
||||||
- `GET /api/cad/{id}/thumbnail` — Thumbnail (kein Auth, UUID opaque)
|
- `GET /api/cad/{id}/thumbnail` — Thumbnail (kein Auth, UUID opaque)
|
||||||
- `POST /api/cad/{id}/generate-stl/{quality}` — STL-Generierung manuell triggern
|
- `POST /api/cad/{id}/generate-gltf-geometry` — Geometry GLB Export triggern
|
||||||
|
- `POST /api/cad/{id}/generate-usd-master` — USD Master Export triggern
|
||||||
|
- `GET /api/cad/{id}/scene-manifest` — Part-Keys mit Material-Zuweisungen
|
||||||
- `POST /api/admin/settings/regenerate-thumbnails` — Alle Thumbnails neu rendern
|
- `POST /api/admin/settings/regenerate-thumbnails` — Alle Thumbnails neu rendern
|
||||||
- `POST /api/admin/settings/process-unprocessed` — Unverarbeitete STEP-Dateien queuen
|
- `POST /api/admin/settings/process-unprocessed` — Unverarbeitete STEP-Dateien queuen
|
||||||
- `POST /api/admin/settings/generate-missing-stls` — Fehlende STL-Caches erstellen
|
|
||||||
- `GET /api/worker/activity` — Letzte 30 STEP-Verarbeitungen (Status, Timing)
|
- `GET /api/worker/activity` — Letzte 30 STEP-Verarbeitungen (Status, Timing)
|
||||||
|
|
||||||
## Bekannte Eigenheiten
|
## Bekannte Eigenheiten
|
||||||
@@ -165,8 +172,9 @@ Beim nächsten Render-Aufruf wird der Cache genutzt (keine Neu-Konvertierung).
|
|||||||
- **Backend-Port 8888** (nicht 8000 — war belegt)
|
- **Backend-Port 8888** (nicht 8000 — war belegt)
|
||||||
- **Tailwind CSS-Variablen**: `bg-surface` etc. funktionieren nicht mit `/ opacity`-Syntax wenn CSS-Variable einen Hex-Wert enthält. Stattdessen `style={{ backgroundColor: 'var(--color-bg-surface)' }}` verwenden.
|
- **Tailwind CSS-Variablen**: `bg-surface` etc. funktionieren nicht mit `/ opacity`-Syntax wenn CSS-Variable einen Hex-Wert enthält. Stattdessen `style={{ backgroundColor: 'var(--color-bg-surface)' }}` verwenden.
|
||||||
- **Blender mm→m**: STEP-Dateien sind in mm, Blender intern in m. Alle Import-Scripts skalieren mit `0.001`.
|
- **Blender mm→m**: STEP-Dateien sind in mm, Blender intern in m. Alle Import-Scripts skalieren mit `0.001`.
|
||||||
- **Flamenco GPU**: `deploy.resources.reservations.devices` in docker-compose für NVIDIA-Support.
|
- **USD Koordinaten**: OCC Z-up → USD Y-up Transformation via Matrix auf `/Root/Assembly` Xform.
|
||||||
- **`settings_persistence`**: Admin-Einstellungen werden via direktem SQL-UPDATE gespeichert (nicht ORM-Mutation), da SQLAlchemy bei key-value-Stores keine Mutation trackt.
|
- **`settings_persistence`**: Admin-Einstellungen werden via direktem SQL-UPDATE gespeichert (nicht ORM-Mutation), da SQLAlchemy bei key-value-Stores keine Mutation trackt.
|
||||||
|
- **Prim-Namen**: USD Prim-Namen dürfen nicht mit Ziffern beginnen. `p_`-Prefix wird automatisch für Teile wie `439505389` gesetzt.
|
||||||
|
|
||||||
## Learnings-Pflicht
|
## Learnings-Pflicht
|
||||||
Nach jedem gelösten Problem oder jeder wichtigen Entscheidung:
|
Nach jedem gelösten Problem oder jeder wichtigen Entscheidung:
|
||||||
|
|||||||
-1174
File diff suppressed because it is too large
Load Diff
+13
-25
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
## 🔎 Status Snapshot
|
## 🔎 Status Snapshot
|
||||||
|
|
||||||
Verified against the repository on `2026-03-11`.
|
Verified against the repository on `2026-03-13`.
|
||||||
|
|
||||||
| Priority | Status | Re-evaluated state |
|
| Priority | Status | Re-evaluated state |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -37,12 +37,12 @@ Verified against the repository on `2026-03-11`.
|
|||||||
| 2. USD Foundation Without Viewer Regression | **Done** | `export_step_to_usd.py`, `import_usd.py`, `usd_master` MediaAsset, `scene-manifest`, `partKey`, `part_key_service.py` all implemented; migrations 060-062 applied |
|
| 2. USD Foundation Without Viewer Regression | **Done** | `export_step_to_usd.py`, `import_usd.py`, `usd_master` MediaAsset, `scene-manifest`, `partKey`, `part_key_service.py` all implemented; migrations 060-062 applied |
|
||||||
| 3. Tessellation and Topology Quality | **Done** | GMSH 4.15.1 installed, `_tessellate_with_gmsh()` implemented, `tessellation_engine` wired admin → pipeline → CLI |
|
| 3. Tessellation and Topology Quality | **Done** | GMSH 4.15.1 installed, `_tessellate_with_gmsh()` implemented, `tessellation_engine` wired admin → pipeline → CLI |
|
||||||
| 4. Viewer Migration to Canonical Part Identity | **Done** | GLB node `extras.partKey` injected; `userData.partKey` stamped in viewer; hover tooltip shows slug; scene manifest verified |
|
| 4. Viewer Migration to Canonical Part Identity | **Done** | GLB node `extras.partKey` injected; `userData.partKey` stamped in viewer; hover tooltip shows slug; scene manifest verified |
|
||||||
| 5. Canonical USD Export and Render Migration | **Done (M1–M3), M5–M7 open** | `import_usd.py` complete; `--usd-path` wired in all render scripts; `render_order_line_task` looks up `usd_master` and passes it through; M4 (deprecation log on production GLB endpoint) added — material metadata and hierarchy fixes required (0/25 parts matched in USD renders) |
|
| 5. Canonical USD Export and Render Migration | **Done** | All milestones complete: M1–M7; production GLB deprecated; digit-only prim name fix (`p_` prefix); `EXPORT_GLB_PRODUCTION` enum removed |
|
||||||
| 7. Render Job Tracking and Structured Logging | **Done** | `RenderJobDocument`, migration `048`, `PipelineLogger`, and revoke-by-real-task-id are present |
|
|
||||||
| 8. Tenant Isolation Completion | **Done (Celery side)** | `set_tenant_context_sync()` called at start of all pipeline tasks; `require_admin` → `require_global_admin` in all 17 admin router functions |
|
|
||||||
| 6. Admin and Product Surface Simplification | **Done** | Settings renamed `scene_*`/`render_*`, migration applied, Admin progressive disclosure, ProductDetail single canonical scene, MediaAssetType deprecated values commented |
|
| 6. Admin and Product Surface Simplification | **Done** | Settings renamed `scene_*`/`render_*`, migration applied, Admin progressive disclosure, ProductDetail single canonical scene, MediaAssetType deprecated values commented |
|
||||||
|
| 7. Render Job Tracking and Structured Logging | **Done** | `RenderJobDocument`, migration `048`, `PipelineLogger`, and revoke-by-real-task-id are present |
|
||||||
|
| 8. Tenant Isolation Completion | **Done** | HTTP: `TenantContextMiddleware` + JWT `tenant_id`; Celery: `set_tenant_context_sync()` in all pipeline tasks; all routers migrated to `require_global_admin` |
|
||||||
| 9. Hash-Based Scene Conversion Caching | **Done** | Composite cache key (hash + deflection + engine) in both geometry and USD tasks; disk existence check; `render_config` stored; `step_hash` in API |
|
| 9. Hash-Based Scene Conversion Caching | **Done** | Composite cache key (hash + deflection + engine) in both geometry and USD tasks; disk existence check; `render_config` stored; `step_hash` in API |
|
||||||
| 10. UI/UX Polish | **Done (M1–M3,M5)** | Empty states, Admin help text, notification batching (all IDs marked read), per-line reject in OrderDetail with portal modal; kanban drag-to-reject deferred |
|
| 10. UI/UX Polish | **Done** | Empty states, Admin help text, notification batching, per-line reject in OrderDetail with portal modal, kanban drag-to-reject with native HTML5 DnD |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -424,28 +424,16 @@ Priority 10 remaining polish — independent
|
|||||||
|
|
||||||
## What To Do Next
|
## What To Do Next
|
||||||
|
|
||||||
**Recommended execution path:**
|
**All 10 original priorities are complete** as of 2026-03-13.
|
||||||
1. Finish the remaining Priority 1 work first: remove STL-era dead code and split `blender_render.py`.
|
|
||||||
2. Start Priority 2 immediately after that cleanup baseline is stable: add `partKey`, assignment layers, and scene manifest without changing browser UX.
|
|
||||||
3. Run Priority 3 in parallel only if cylinder tessellation is actively blocking confidence in seam/sharp payload work; otherwise keep it behind Priority 2.
|
|
||||||
4. Treat Priority 8 as a short parallel hardening task: add Celery-side tenant context propagation.
|
|
||||||
5. Use `docs/plans/0001-step-to-usd-implementation.md` as the execution checklist for the USD workstream.
|
|
||||||
|
|
||||||
**Parallel sprint option (2 agents):**
|
The only deferred item is **P10 M5 — Kanban drag-to-reject** (drag order cards to a "Rejected" column with a reason field). This is tracked in `plan.md`.
|
||||||
- Agent 1: Priority 1 remainder (dead-code cleanup + `blender_render.py` split)
|
|
||||||
- Agent 2: Priority 8 remainder or Priority 3, depending on whether tessellation quality is currently blocking work
|
|
||||||
|
|
||||||
**Parallel sprint option (3 agents):**
|
**Potential future work (not yet planned):**
|
||||||
- Agent 1: Priority 1 remainder
|
- Automated test suite (currently no tests)
|
||||||
- Agent 2: Priority 2 groundwork (`usd_master`, `part_key_service`, `scene-manifest`)
|
- Performance profiling for large assemblies (100+ parts)
|
||||||
- Agent 3: Priority 8 remainder or targeted Priority 10 polish
|
- Batch material assignment UI improvements
|
||||||
|
- Additional USD features (instancing, LOD)
|
||||||
**Do not defer anymore:**
|
- Production deployment hardening (health checks, monitoring)
|
||||||
- canonical `partKey`
|
|
||||||
- part-keyed browser material overrides
|
|
||||||
- scene manifest / preview contract
|
|
||||||
|
|
||||||
These are now considered implementation prerequisites for the long-term refactor, not optional strategy work.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -303,34 +303,6 @@ async def generate_gltf_geometry(
|
|||||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{id}/generate-gltf-production", status_code=status.HTTP_202_ACCEPTED)
|
|
||||||
async def generate_gltf_production(
|
|
||||||
id: uuid.UUID,
|
|
||||||
user: User = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Queue production GLB export (Blender + PBR materials) from a geometry GLB.
|
|
||||||
|
|
||||||
Requires a gltf_geometry MediaAsset to already exist (run generate-gltf-geometry first).
|
|
||||||
Stores result as a MediaAsset with asset_type='gltf_production'.
|
|
||||||
"""
|
|
||||||
if not is_privileged(user):
|
|
||||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
||||||
|
|
||||||
cad = await _get_cad_file(id, db)
|
|
||||||
if not cad.stored_path:
|
|
||||||
raise HTTPException(status_code=404, detail="STEP file not uploaded for this CAD file")
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
"generate_gltf_production called for cad %s — "
|
|
||||||
"deprecated: renders now consume usd_master directly",
|
|
||||||
id,
|
|
||||||
)
|
|
||||||
from app.tasks.step_tasks import generate_gltf_production_task
|
|
||||||
task = generate_gltf_production_task.delay(str(id))
|
|
||||||
return {"status": "queued", "task_id": task.id, "cad_file_id": str(id)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{id}/regenerate-thumbnail",
|
"/{id}/regenerate-thumbnail",
|
||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.domains.rendering.schemas import (
|
|||||||
GlobalRenderPositionPatch,
|
GlobalRenderPositionPatch,
|
||||||
GlobalRenderPositionOut,
|
GlobalRenderPositionOut,
|
||||||
)
|
)
|
||||||
from app.utils.auth import require_admin, get_current_user
|
from app.utils.auth import require_global_admin, get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/render-positions/global", tags=["global-render-positions"])
|
router = APIRouter(prefix="/render-positions/global", tags=["global-render-positions"])
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ async def list_global_render_positions(
|
|||||||
async def create_global_render_position(
|
async def create_global_render_position(
|
||||||
body: GlobalRenderPositionCreate,
|
body: GlobalRenderPositionCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_user=Depends(require_admin),
|
_user=Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Create a new global render position (admin only)."""
|
"""Create a new global render position (admin only)."""
|
||||||
pos = GlobalRenderPosition(**body.model_dump())
|
pos = GlobalRenderPosition(**body.model_dump())
|
||||||
@@ -46,7 +46,7 @@ async def update_global_render_position(
|
|||||||
pos_id: uuid.UUID,
|
pos_id: uuid.UUID,
|
||||||
body: GlobalRenderPositionPatch,
|
body: GlobalRenderPositionPatch,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_user=Depends(require_admin),
|
_user=Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Update a global render position (admin only)."""
|
"""Update a global render position (admin only)."""
|
||||||
result = await db.execute(select(GlobalRenderPosition).where(GlobalRenderPosition.id == pos_id))
|
result = await db.execute(select(GlobalRenderPosition).where(GlobalRenderPosition.id == pos_id))
|
||||||
@@ -64,7 +64,7 @@ async def update_global_render_position(
|
|||||||
async def delete_global_render_position(
|
async def delete_global_render_position(
|
||||||
pos_id: uuid.UUID,
|
pos_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_user=Depends(require_admin),
|
_user=Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Delete a global render position (admin only)."""
|
"""Delete a global render position (admin only)."""
|
||||||
result = await db.execute(select(GlobalRenderPosition).where(GlobalRenderPosition.id == pos_id))
|
result = await db.execute(select(GlobalRenderPosition).where(GlobalRenderPosition.id == pos_id))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.template import Template
|
from app.models.template import Template
|
||||||
from app.utils.auth import get_current_user, require_admin
|
from app.utils.auth import get_current_user, require_global_admin
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
router = APIRouter(prefix="/templates", tags=["templates"])
|
router = APIRouter(prefix="/templates", tags=["templates"])
|
||||||
@@ -63,7 +63,7 @@ async def get_template(
|
|||||||
async def update_template(
|
async def update_template(
|
||||||
template_id: uuid.UUID,
|
template_id: uuid.UUID,
|
||||||
body: TemplateUpdate,
|
body: TemplateUpdate,
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Template).where(Template.id == template_id))
|
result = await db.execute(select(Template).where(Template.id == template_id))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.models.product import Product
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.worker_config import WorkerConfig
|
from app.models.worker_config import WorkerConfig
|
||||||
from app.models.system_setting import SystemSetting
|
from app.models.system_setting import SystemSetting
|
||||||
from app.utils.auth import get_current_user, require_admin_or_pm, require_admin
|
from app.utils.auth import get_current_user, require_admin_or_pm, require_global_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/worker", tags=["worker"])
|
router = APIRouter(prefix="/worker", tags=["worker"])
|
||||||
|
|
||||||
@@ -364,7 +364,7 @@ async def cancel_task(task_id: str, user: User = Depends(require_admin_or_pm)):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class ScaleRequest(BaseModel):
|
class ScaleRequest(BaseModel):
|
||||||
service: str # "render-worker" | "worker" | "worker-thumbnail"
|
service: str # "render-worker" | "worker"
|
||||||
count: int # 0–20
|
count: int # 0–20
|
||||||
|
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ async def scale_workers(
|
|||||||
body: ScaleRequest,
|
body: ScaleRequest,
|
||||||
user: User = Depends(require_admin_or_pm),
|
user: User = Depends(require_admin_or_pm),
|
||||||
):
|
):
|
||||||
"""Scale a Compose service (render-worker, worker, worker-thumbnail) up or down.
|
"""Scale a Compose service (render-worker, worker) up or down.
|
||||||
|
|
||||||
Requires the docker socket and compose file to be accessible inside the container
|
Requires the docker socket and compose file to be accessible inside the container
|
||||||
(see docker-compose.yml COMPOSE_PROJECT_DIR env var).
|
(see docker-compose.yml COMPOSE_PROJECT_DIR env var).
|
||||||
@@ -421,7 +421,7 @@ async def scale_workers(
|
|||||||
import subprocess
|
import subprocess
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
ALLOWED_SERVICES = {"render-worker", "worker", "worker-thumbnail"}
|
ALLOWED_SERVICES = {"render-worker", "worker"}
|
||||||
if body.service not in ALLOWED_SERVICES:
|
if body.service not in ALLOWED_SERVICES:
|
||||||
raise HTTPException(400, detail=f"service must be one of {ALLOWED_SERVICES}")
|
raise HTTPException(400, detail=f"service must be one of {ALLOWED_SERVICES}")
|
||||||
if not (0 <= body.count <= 20):
|
if not (0 <= body.count <= 20):
|
||||||
@@ -462,7 +462,7 @@ async def scale_workers(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.post("/probe/gpu", status_code=http_status.HTTP_202_ACCEPTED)
|
@router.post("/probe/gpu", status_code=http_status.HTTP_202_ACCEPTED)
|
||||||
async def trigger_gpu_probe(current_user: User = Depends(require_admin)):
|
async def trigger_gpu_probe(current_user: User = Depends(require_global_admin)):
|
||||||
"""Queue a GPU probe task on the render-worker."""
|
"""Queue a GPU probe task on the render-worker."""
|
||||||
from app.tasks.gpu_tasks import probe_gpu
|
from app.tasks.gpu_tasks import probe_gpu
|
||||||
result = probe_gpu.delay()
|
result = probe_gpu.delay()
|
||||||
@@ -471,7 +471,7 @@ async def trigger_gpu_probe(current_user: User = Depends(require_admin)):
|
|||||||
|
|
||||||
@router.get("/probe/gpu/result")
|
@router.get("/probe/gpu/result")
|
||||||
async def get_gpu_probe_result(
|
async def get_gpu_probe_result(
|
||||||
current_user: User = Depends(require_admin),
|
current_user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Return the last GPU probe result from system_settings."""
|
"""Return the last GPU probe result from system_settings."""
|
||||||
@@ -622,7 +622,7 @@ class WorkerConfigUpdate(BaseModel):
|
|||||||
|
|
||||||
@router.get("/configs", response_model=list[WorkerConfigOut])
|
@router.get("/configs", response_model=list[WorkerConfigOut])
|
||||||
async def list_worker_configs(
|
async def list_worker_configs(
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List all worker concurrency configurations (admin only)."""
|
"""List all worker concurrency configurations (admin only)."""
|
||||||
@@ -644,7 +644,7 @@ async def list_worker_configs(
|
|||||||
async def update_worker_config(
|
async def update_worker_config(
|
||||||
queue_name: str,
|
queue_name: str,
|
||||||
body: WorkerConfigUpdate,
|
body: WorkerConfigUpdate,
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update concurrency settings for a specific queue (admin only)."""
|
"""Update concurrency settings for a specific queue (admin only)."""
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class StepName(StrEnum):
|
|||||||
|
|
||||||
# ── GLB / asset export ────────────────────────────────────────────
|
# ── GLB / asset export ────────────────────────────────────────────
|
||||||
EXPORT_GLB_GEOMETRY = "export_glb_geometry"
|
EXPORT_GLB_GEOMETRY = "export_glb_geometry"
|
||||||
EXPORT_GLB_PRODUCTION = "export_glb_production"
|
|
||||||
EXPORT_BLEND = "export_blend"
|
EXPORT_BLEND = "export_blend"
|
||||||
|
|
||||||
# ── STL cache ────────────────────────────────────────────────────
|
# ── STL cache ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from app.domains.admin.dashboard_service import (
|
|||||||
upsert_user_dashboard_config,
|
upsert_user_dashboard_config,
|
||||||
upsert_tenant_default,
|
upsert_tenant_default,
|
||||||
)
|
)
|
||||||
from app.utils.auth import get_current_user, require_admin
|
from app.utils.auth import get_current_user, require_global_admin
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -107,7 +107,7 @@ async def update_config(
|
|||||||
|
|
||||||
@router.get("/tenant-default", response_model=DashboardConfigResponse)
|
@router.get("/tenant-default", response_model=DashboardConfigResponse)
|
||||||
async def get_tenant_default(
|
async def get_tenant_default(
|
||||||
current_user: User = Depends(require_admin),
|
current_user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> DashboardConfigResponse:
|
) -> DashboardConfigResponse:
|
||||||
"""Load the tenant-default dashboard widget config (admin only)."""
|
"""Load the tenant-default dashboard widget config (admin only)."""
|
||||||
@@ -132,7 +132,7 @@ async def get_tenant_default(
|
|||||||
@router.put("/tenant-default", response_model=DashboardConfigResponse)
|
@router.put("/tenant-default", response_model=DashboardConfigResponse)
|
||||||
async def update_tenant_default(
|
async def update_tenant_default(
|
||||||
payload: DashboardConfigPayload,
|
payload: DashboardConfigPayload,
|
||||||
current_user: User = Depends(require_admin),
|
current_user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> DashboardConfigResponse:
|
) -> DashboardConfigResponse:
|
||||||
"""Set the tenant-default widget config (admin only)."""
|
"""Set the tenant-default widget config (admin only)."""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""GLB/GLTF export tasks.
|
"""GLB/GLTF and USD export tasks.
|
||||||
|
|
||||||
Covers:
|
Covers:
|
||||||
- generate_gltf_geometry_task — OCC STEP → geometry GLB (fast preview)
|
- generate_gltf_geometry_task — OCC STEP → geometry GLB (fast preview)
|
||||||
- generate_gltf_production_task — OCC STEP → production GLB (Blender PBR materials)
|
- generate_usd_master_task — OCC STEP → USD canonical scene (pxr authoring)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -251,283 +251,6 @@ def generate_gltf_geometry_task(self, cad_file_id: str):
|
|||||||
_r.delete(_lock_key)
|
_r.delete(_lock_key)
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(
|
|
||||||
bind=True,
|
|
||||||
name="app.tasks.step_tasks.generate_gltf_production_task",
|
|
||||||
queue="asset_pipeline",
|
|
||||||
max_retries=2,
|
|
||||||
)
|
|
||||||
def generate_gltf_production_task(self, cad_file_id: str, product_id: str | None = None) -> dict:
|
|
||||||
"""Generate a production GLB (Blender + PBR materials) from a geometry GLB via export_gltf.py.
|
|
||||||
|
|
||||||
1. Ensures a gltf_geometry MediaAsset exists (runs OCC export inline if not).
|
|
||||||
2. Resolves SCHAEFFLER material map for the CadFile's product.
|
|
||||||
3. Runs Blender headless with export_gltf.py → production GLB.
|
|
||||||
4. Stores result as gltf_production MediaAsset.
|
|
||||||
"""
|
|
||||||
import json as _json
|
|
||||||
import os as _os
|
|
||||||
import subprocess as _subprocess
|
|
||||||
import sys as _sys
|
|
||||||
import uuid as _uuid
|
|
||||||
from pathlib import Path as _Path
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine as _ce, delete as _del, select as _sel, update as _upd
|
|
||||||
from sqlalchemy.orm import Session as _Session
|
|
||||||
|
|
||||||
from app.config import settings as app_settings
|
|
||||||
from app.domains.media.models import MediaAsset, MediaAssetType
|
|
||||||
from app.services.render_blender import find_blender, is_blender_available
|
|
||||||
|
|
||||||
pl = PipelineLogger(task_id=self.request.id)
|
|
||||||
pl.step_start("export_glb_production", {"cad_file_id": cad_file_id})
|
|
||||||
log_task_event(self.request.id, f"generate_gltf_production_task started for cad {cad_file_id}", "info")
|
|
||||||
|
|
||||||
# Resolve and log tenant context at task start (required for RLS)
|
|
||||||
from app.core.tenant_context import resolve_tenant_id_for_cad, set_tenant_context_sync
|
|
||||||
_tenant_id = resolve_tenant_id_for_cad(cad_file_id)
|
|
||||||
|
|
||||||
_sync_url = app_settings.database_url.replace("+asyncpg", "")
|
|
||||||
_eng = _ce(_sync_url)
|
|
||||||
|
|
||||||
# --- 1. Resolve STEP file path and system settings ---
|
|
||||||
from app.models.cad_file import CadFile as _CF
|
|
||||||
from app.models.system_setting import SystemSetting
|
|
||||||
|
|
||||||
with _Session(_eng) as _sess:
|
|
||||||
set_tenant_context_sync(_sess, _tenant_id)
|
|
||||||
_cad = _sess.execute(
|
|
||||||
_sel(_CF).where(_CF.id == _uuid.UUID(cad_file_id))
|
|
||||||
).scalar_one_or_none()
|
|
||||||
step_path_str = _cad.stored_path if _cad else None
|
|
||||||
cad_mesh_attributes: dict = (_cad.mesh_attributes or {}) if _cad else {}
|
|
||||||
|
|
||||||
settings_rows = _sess.execute(_sel(SystemSetting)).scalars().all()
|
|
||||||
sys_settings = {s.key: s.value for s in settings_rows}
|
|
||||||
|
|
||||||
if not step_path_str:
|
|
||||||
raise RuntimeError(f"CadFile {cad_file_id} not found in DB")
|
|
||||||
step_path = _Path(step_path_str)
|
|
||||||
if not step_path.exists():
|
|
||||||
raise RuntimeError(f"STEP file not found: {step_path}")
|
|
||||||
|
|
||||||
smooth_angle = float(sys_settings.get("blender_smooth_angle", "30"))
|
|
||||||
prod_linear = float(sys_settings.get("render_linear_deflection", "0.03"))
|
|
||||||
prod_angular = float(sys_settings.get("render_angular_deflection", "0.05"))
|
|
||||||
tessellation_engine = sys_settings.get("tessellation_engine", "occ")
|
|
||||||
|
|
||||||
scripts_dir = _Path(_os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts"))
|
|
||||||
occ_script = scripts_dir / "export_step_to_gltf.py"
|
|
||||||
if not occ_script.exists():
|
|
||||||
raise RuntimeError(f"export_step_to_gltf.py not found at {occ_script}")
|
|
||||||
|
|
||||||
prod_geom_glb = step_path.parent / f"{step_path.stem}_production_geom.glb"
|
|
||||||
python_bin = _sys.executable
|
|
||||||
sharp_threshold = float(sys_settings.get("sharp_edge_threshold", "20.0"))
|
|
||||||
|
|
||||||
# --- Geometry GLB selection strategy ---
|
|
||||||
# When GMSH is enabled, the geometry GLB (_geometry.glb) is already a conforming
|
|
||||||
# mesh with correct seam topology — GMSH quality comes from the algorithm, not density.
|
|
||||||
# Re-tessellating at finer production settings only wastes time and RAM on large assemblies.
|
|
||||||
# → For GMSH: reuse the existing _geometry.glb if it is newer than the STEP file.
|
|
||||||
# → For OCC: generate a separate _production_geom.glb at finer settings (density matters).
|
|
||||||
|
|
||||||
step_mtime = step_path.stat().st_mtime if step_path.exists() else 0
|
|
||||||
preview_glb = step_path.parent / f"{step_path.stem}_geometry.glb"
|
|
||||||
|
|
||||||
preview_glb_valid = (
|
|
||||||
preview_glb.exists()
|
|
||||||
and preview_glb.stat().st_size > 0
|
|
||||||
and preview_glb.stat().st_mtime >= step_mtime
|
|
||||||
)
|
|
||||||
prod_geom_cache_valid = (
|
|
||||||
prod_geom_glb.exists()
|
|
||||||
and prod_geom_glb.stat().st_size > 0
|
|
||||||
and prod_geom_glb.stat().st_mtime >= step_mtime
|
|
||||||
)
|
|
||||||
|
|
||||||
if tessellation_engine == "gmsh" and preview_glb_valid:
|
|
||||||
# Fast path: reuse geometry GLB — GMSH topology is already correct at preview quality
|
|
||||||
geom_glb_path = preview_glb
|
|
||||||
log_task_event(
|
|
||||||
self.request.id,
|
|
||||||
f"GMSH: reusing geometry GLB as Blender input ({preview_glb.stat().st_size // 1024}KB, "
|
|
||||||
f"no re-tessellation needed)",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
elif prod_geom_cache_valid:
|
|
||||||
# Cache hit: production_geom.glb exists and is up-to-date
|
|
||||||
geom_glb_path = prod_geom_glb
|
|
||||||
log_task_event(
|
|
||||||
self.request.id,
|
|
||||||
f"Cache hit: reusing production geometry GLB ({prod_geom_glb.stat().st_size // 1024}KB)",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# No usable cache: run tessellation from STEP.
|
|
||||||
# When GMSH is selected, force preview-quality settings (0.1mm / 0.1rad) even here.
|
|
||||||
# Fine production settings (e.g. 0.03mm) combined with GMSH OOM-kill on large assemblies
|
|
||||||
# because CharacteristicLengthMax becomes too small. GMSH quality is algorithmic
|
|
||||||
# (conforming seams) not density-based — a denser GMSH mesh adds no UV-unwrap benefit.
|
|
||||||
if tessellation_engine == "gmsh":
|
|
||||||
eff_linear = float(sys_settings.get("scene_linear_deflection", "0.1"))
|
|
||||||
eff_angular = float(sys_settings.get("scene_angular_deflection", "0.1"))
|
|
||||||
else:
|
|
||||||
eff_linear = prod_linear
|
|
||||||
eff_angular = prod_angular
|
|
||||||
occ_cmd = [
|
|
||||||
python_bin, str(occ_script),
|
|
||||||
"--step_path", str(step_path),
|
|
||||||
"--output_path", str(prod_geom_glb),
|
|
||||||
"--linear_deflection", str(eff_linear),
|
|
||||||
"--angular_deflection", str(eff_angular),
|
|
||||||
"--sharp_threshold", str(sharp_threshold),
|
|
||||||
"--tessellation_engine", tessellation_engine,
|
|
||||||
]
|
|
||||||
log_task_event(
|
|
||||||
self.request.id,
|
|
||||||
f"Tessellating STEP for production ({tessellation_engine}, "
|
|
||||||
f"linear={eff_linear}mm, angular={eff_angular}rad)",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
occ_result = _subprocess.run(occ_cmd, capture_output=True, text=True, timeout=600)
|
|
||||||
for line in occ_result.stdout.splitlines():
|
|
||||||
logger.info("[occ-prod] %s", line)
|
|
||||||
if occ_result.returncode != 0 or not prod_geom_glb.exists() or prod_geom_glb.stat().st_size == 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"OCC export failed (exit {occ_result.returncode}): {occ_result.stderr[-500:]}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log_task_event(self.request.id, f"OCC re-export failed: {exc}", "error")
|
|
||||||
pl.step_error("export_glb_production", f"OCC re-export failed: {exc}", exc)
|
|
||||||
raise self.retry(exc=exc, countdown=30)
|
|
||||||
geom_glb_path = prod_geom_glb
|
|
||||||
|
|
||||||
# --- 2. Resolve material map from Product.cad_part_materials (SCHAEFFLER library names) ---
|
|
||||||
# cad_part_materials lives on Product (list[dict]), NOT on CadFile.
|
|
||||||
# We look up the Product that owns this CadFile (prefer product_id arg if given).
|
|
||||||
from app.services.material_service import resolve_material_map
|
|
||||||
from app.domains.products.models import Product as _Product
|
|
||||||
|
|
||||||
with _Session(_eng) as _sess:
|
|
||||||
set_tenant_context_sync(_sess, _tenant_id)
|
|
||||||
_prod_query = _sel(_Product).where(_Product.cad_file_id == _uuid.UUID(cad_file_id))
|
|
||||||
if product_id:
|
|
||||||
_prod_query = _prod_query.where(_Product.id == _uuid.UUID(product_id))
|
|
||||||
_product = _sess.execute(_prod_query).scalars().first()
|
|
||||||
raw_materials: list[dict] = _product.cad_part_materials if _product else []
|
|
||||||
|
|
||||||
# Convert list[{"part_name": X, "material": Y}] → dict[str, str] for resolve_material_map
|
|
||||||
raw_mat_map: dict[str, str] = {
|
|
||||||
m["part_name"]: m["material"]
|
|
||||||
for m in raw_materials
|
|
||||||
if m.get("part_name") and m.get("material")
|
|
||||||
}
|
|
||||||
mat_map = resolve_material_map(raw_mat_map)
|
|
||||||
logger.info(
|
|
||||||
"generate_gltf_production_task: resolved %d material(s) for cad %s (product: %s)",
|
|
||||||
len(mat_map), cad_file_id, _product.id if _product else "none",
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- 3. Run Blender: apply materials + smooth shading + export production GLB ---
|
|
||||||
# Use get_material_library_path() which checks active AssetLibrary first,
|
|
||||||
# then falls back to the legacy material_library_path system setting.
|
|
||||||
from app.services.template_service import get_material_library_path
|
|
||||||
asset_library_blend = get_material_library_path() or ""
|
|
||||||
_eng.dispose()
|
|
||||||
|
|
||||||
output_path = step_path.parent / f"{step_path.stem}_production.glb"
|
|
||||||
|
|
||||||
export_script = scripts_dir / "export_gltf.py"
|
|
||||||
if not is_blender_available():
|
|
||||||
raise RuntimeError("Blender is not available — cannot generate production GLB")
|
|
||||||
if not export_script.exists():
|
|
||||||
raise RuntimeError(f"export_gltf.py not found at {export_script}")
|
|
||||||
|
|
||||||
blender_bin = find_blender()
|
|
||||||
cmd = [
|
|
||||||
blender_bin, "--background",
|
|
||||||
"--python", str(export_script),
|
|
||||||
"--",
|
|
||||||
"--glb_path", str(geom_glb_path),
|
|
||||||
"--output_path", str(output_path),
|
|
||||||
"--material_map", _json.dumps(mat_map),
|
|
||||||
"--smooth_angle", str(smooth_angle),
|
|
||||||
"--mesh_attributes", _json.dumps(cad_mesh_attributes),
|
|
||||||
]
|
|
||||||
if asset_library_blend:
|
|
||||||
cmd += ["--asset_library_blend", asset_library_blend]
|
|
||||||
|
|
||||||
log_task_event(
|
|
||||||
self.request.id,
|
|
||||||
f"Running Blender export_gltf.py — {len(mat_map)} material(s), smooth={smooth_angle}°",
|
|
||||||
"info",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
result = _subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
logger.info("[export-gltf] %s", line)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"export_gltf.py exited {result.returncode}:\n{result.stderr[-500:]}"
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log_task_event(self.request.id, f"Blender production GLB failed: {exc}", "error")
|
|
||||||
pl.step_error("export_glb_production", f"Blender production GLB failed: {exc}", exc)
|
|
||||||
logger.error("generate_gltf_production_task Blender failed for cad %s: %s", cad_file_id, exc)
|
|
||||||
raise self.retry(exc=exc, countdown=30)
|
|
||||||
# Note: _production_geom.glb is intentionally kept on disk as a tessellation cache.
|
|
||||||
# It is reused on subsequent runs when the STEP file hasn't changed.
|
|
||||||
|
|
||||||
log_task_event(self.request.id, f"Production GLB exported: {output_path.name}", "done")
|
|
||||||
|
|
||||||
# --- 4. Store MediaAsset (upsert: update existing record to keep stable ID/URL) ---
|
|
||||||
# Updating in-place (not DELETE+INSERT) preserves the existing asset UUID so that
|
|
||||||
# any frontend page holding a stale download_url continues to resolve correctly.
|
|
||||||
_eng2 = _ce(_sync_url)
|
|
||||||
with _Session(_eng2) as _sess:
|
|
||||||
set_tenant_context_sync(_sess, _tenant_id)
|
|
||||||
_key = str(output_path)
|
|
||||||
_prefix = str(app_settings.upload_dir).rstrip("/") + "/"
|
|
||||||
if _key.startswith(_prefix):
|
|
||||||
_key = _key[len(_prefix):]
|
|
||||||
_file_size = output_path.stat().st_size if output_path.exists() else None
|
|
||||||
|
|
||||||
existing = _sess.execute(
|
|
||||||
_sel(MediaAsset).where(
|
|
||||||
MediaAsset.cad_file_id == _uuid.UUID(cad_file_id),
|
|
||||||
MediaAsset.asset_type == MediaAssetType.gltf_production,
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.storage_key = _key
|
|
||||||
existing.mime_type = "model/gltf-binary"
|
|
||||||
existing.file_size_bytes = _file_size
|
|
||||||
if product_id:
|
|
||||||
existing.product_id = _uuid.UUID(product_id)
|
|
||||||
_sess.commit()
|
|
||||||
asset_id = str(existing.id)
|
|
||||||
else:
|
|
||||||
asset = MediaAsset(
|
|
||||||
cad_file_id=_uuid.UUID(cad_file_id),
|
|
||||||
product_id=_uuid.UUID(product_id) if product_id else None,
|
|
||||||
asset_type=MediaAssetType.gltf_production,
|
|
||||||
storage_key=_key,
|
|
||||||
mime_type="model/gltf-binary",
|
|
||||||
file_size_bytes=_file_size,
|
|
||||||
)
|
|
||||||
_sess.add(asset)
|
|
||||||
_sess.commit()
|
|
||||||
asset_id = str(asset.id)
|
|
||||||
_eng2.dispose()
|
|
||||||
|
|
||||||
pl.step_done("export_glb_production", result={"glb_path": str(output_path), "asset_id": asset_id})
|
|
||||||
logger.info("generate_gltf_production_task: MediaAsset %s created for cad %s", asset_id, cad_file_id)
|
|
||||||
return {"glb_path": str(output_path), "asset_id": asset_id}
|
|
||||||
|
|
||||||
|
|
||||||
@celery_app.task(
|
@celery_app.task(
|
||||||
bind=True,
|
bind=True,
|
||||||
name="app.tasks.step_tasks.generate_usd_master_task",
|
name="app.tasks.step_tasks.generate_usd_master_task",
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ STEP_TASK_MAP: dict[StepName, str] = {
|
|||||||
# StepName.ORDER_LINE_SETUP — computed inline inside render_order_line_task
|
# StepName.ORDER_LINE_SETUP — computed inline inside render_order_line_task
|
||||||
# StepName.RESOLVE_TEMPLATE — computed inline inside render_order_line_task
|
# StepName.RESOLVE_TEMPLATE — computed inline inside render_order_line_task
|
||||||
# StepName.OUTPUT_SAVE — handled via publish_asset after render tasks
|
# StepName.OUTPUT_SAVE — handled via publish_asset after render tasks
|
||||||
# StepName.EXPORT_GLB_PRODUCTION — app.tasks.step_tasks.generate_gltf_production_task
|
|
||||||
StepName.EXPORT_GLB_PRODUCTION: "app.tasks.step_tasks.generate_gltf_production_task",
|
|
||||||
# StepName.NOTIFY — emitted inline via notification_service
|
# StepName.NOTIFY — emitted inline via notification_service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.domains.auth.models import User
|
from app.domains.auth.models import User
|
||||||
from app.utils.auth import get_current_user, require_admin, require_admin_or_pm, require_pm_or_above
|
from app.utils.auth import get_current_user, require_global_admin, require_admin_or_pm, require_pm_or_above
|
||||||
from app.domains.rendering.models import WorkflowDefinition, WorkflowRun
|
from app.domains.rendering.models import WorkflowDefinition, WorkflowRun
|
||||||
from app.domains.rendering.schemas import (
|
from app.domains.rendering.schemas import (
|
||||||
WorkflowDefinitionCreate,
|
WorkflowDefinitionCreate,
|
||||||
@@ -52,7 +52,6 @@ _STEP_CATEGORIES: dict[StepName, StepCategory] = {
|
|||||||
StepName.BLENDER_TURNTABLE: "rendering",
|
StepName.BLENDER_TURNTABLE: "rendering",
|
||||||
StepName.OUTPUT_SAVE: "output",
|
StepName.OUTPUT_SAVE: "output",
|
||||||
StepName.EXPORT_GLB_GEOMETRY: "output",
|
StepName.EXPORT_GLB_GEOMETRY: "output",
|
||||||
StepName.EXPORT_GLB_PRODUCTION: "output",
|
|
||||||
StepName.EXPORT_BLEND: "output",
|
StepName.EXPORT_BLEND: "output",
|
||||||
StepName.STL_CACHE_GENERATE: "processing",
|
StepName.STL_CACHE_GENERATE: "processing",
|
||||||
StepName.NOTIFY: "output",
|
StepName.NOTIFY: "output",
|
||||||
@@ -74,7 +73,6 @@ _STEP_DESCRIPTIONS: dict[StepName, str] = {
|
|||||||
StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service",
|
StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service",
|
||||||
StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record",
|
StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record",
|
||||||
StepName.EXPORT_GLB_GEOMETRY: "Export a geometry-only GLB for the 3-D viewer (no materials)",
|
StepName.EXPORT_GLB_GEOMETRY: "Export a geometry-only GLB for the 3-D viewer (no materials)",
|
||||||
StepName.EXPORT_GLB_PRODUCTION: "Export a production GLB with full materials from the .blend template",
|
|
||||||
StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset",
|
StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset",
|
||||||
StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file",
|
StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file",
|
||||||
StepName.NOTIFY: "Emit a user notification via the audit-log notification channel",
|
StepName.NOTIFY: "Emit a user notification via the audit-log notification channel",
|
||||||
@@ -140,7 +138,7 @@ async def get_workflow(
|
|||||||
@router.post("", response_model=WorkflowDefinitionOut, status_code=201)
|
@router.post("", response_model=WorkflowDefinitionOut, status_code=201)
|
||||||
async def create_workflow(
|
async def create_workflow(
|
||||||
body: WorkflowDefinitionCreate,
|
body: WorkflowDefinitionCreate,
|
||||||
_user: User = Depends(require_admin),
|
_user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if body.config:
|
if body.config:
|
||||||
@@ -164,7 +162,7 @@ async def create_workflow(
|
|||||||
async def update_workflow(
|
async def update_workflow(
|
||||||
workflow_id: uuid.UUID,
|
workflow_id: uuid.UUID,
|
||||||
body: WorkflowDefinitionUpdate,
|
body: WorkflowDefinitionUpdate,
|
||||||
_user: User = Depends(require_admin),
|
_user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -193,7 +191,7 @@ async def update_workflow(
|
|||||||
@router.delete("/{workflow_id}", status_code=204)
|
@router.delete("/{workflow_id}", status_code=204)
|
||||||
async def delete_workflow(
|
async def delete_workflow(
|
||||||
workflow_id: uuid.UUID,
|
workflow_id: uuid.UUID,
|
||||||
_user: User = Depends(require_admin),
|
_user: User = Depends(require_global_admin),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import update
|
from sqlalchemy import update
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.utils.auth import require_admin
|
from app.utils.auth import require_global_admin
|
||||||
from app.domains.tenants.schemas import (
|
from app.domains.tenants.schemas import (
|
||||||
TenantCreate, TenantUpdate, TenantOut,
|
TenantCreate, TenantUpdate, TenantOut,
|
||||||
TenantAIConfigUpdate, TenantAIConfigOut,
|
TenantAIConfigUpdate, TenantAIConfigOut,
|
||||||
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/tenants", tags=["tenants"])
|
|||||||
@router.get("/", response_model=list[TenantOut])
|
@router.get("/", response_model=list[TenantOut])
|
||||||
async def list_tenants(
|
async def list_tenants(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
rows = await service.list_tenants(db)
|
rows = await service.list_tenants(db)
|
||||||
result = []
|
result = []
|
||||||
@@ -34,7 +34,7 @@ async def list_tenants(
|
|||||||
async def get_tenant(
|
async def get_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
tenant = await service.get_tenant(db, tenant_id)
|
tenant = await service.get_tenant(db, tenant_id)
|
||||||
if not tenant:
|
if not tenant:
|
||||||
@@ -46,7 +46,7 @@ async def get_tenant(
|
|||||||
async def create_tenant(
|
async def create_tenant(
|
||||||
body: TenantCreate,
|
body: TenantCreate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
tenant = await service.create_tenant(db, name=body.name, slug=body.slug, is_active=body.is_active)
|
tenant = await service.create_tenant(db, name=body.name, slug=body.slug, is_active=body.is_active)
|
||||||
return TenantOut.model_validate(tenant)
|
return TenantOut.model_validate(tenant)
|
||||||
@@ -57,7 +57,7 @@ async def update_tenant(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
body: TenantUpdate,
|
body: TenantUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
tenant = await service.update_tenant(
|
tenant = await service.update_tenant(
|
||||||
db, tenant_id,
|
db, tenant_id,
|
||||||
@@ -74,7 +74,7 @@ async def update_tenant(
|
|||||||
async def delete_tenant(
|
async def delete_tenant(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
ok = await service.delete_tenant(db, tenant_id)
|
ok = await service.delete_tenant(db, tenant_id)
|
||||||
if not ok:
|
if not ok:
|
||||||
@@ -107,7 +107,7 @@ def _tenant_ai_config_out(tenant: Tenant) -> TenantAIConfigOut:
|
|||||||
async def get_tenant_ai_config(
|
async def get_tenant_ai_config(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Return AI config for a tenant (without the raw api_key)."""
|
"""Return AI config for a tenant (without the raw api_key)."""
|
||||||
tenant = await service.get_tenant(db, tenant_id)
|
tenant = await service.get_tenant(db, tenant_id)
|
||||||
@@ -121,7 +121,7 @@ async def update_tenant_ai_config(
|
|||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
body: TenantAIConfigUpdate,
|
body: TenantAIConfigUpdate,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Merge AI configuration into tenant_config JSONB.
|
"""Merge AI configuration into tenant_config JSONB.
|
||||||
If ai_api_key is None in the request body, the existing key is preserved.
|
If ai_api_key is None in the request body, the existing key is preserved.
|
||||||
@@ -160,7 +160,7 @@ async def update_tenant_ai_config(
|
|||||||
async def test_tenant_ai_config(
|
async def test_tenant_ai_config(
|
||||||
tenant_id: uuid.UUID,
|
tenant_id: uuid.UUID,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: object = Depends(require_admin),
|
_: object = Depends(require_global_admin),
|
||||||
):
|
):
|
||||||
"""Send a minimal ping to Azure OpenAI using the tenant's stored credentials.
|
"""Send a minimal ping to Azure OpenAI using the tenant's stored credentials.
|
||||||
Returns {"ok": true} or {"ok": false, "error": "human readable message"}.
|
Returns {"ok": true} or {"ok": false, "error": "human readable message"}.
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ from app.domains.pipeline.tasks.render_order_line import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from app.domains.pipeline.tasks.export_glb import ( # noqa: F401
|
from app.domains.pipeline.tasks.export_glb import ( # noqa: F401
|
||||||
generate_gltf_geometry_task,
|
generate_gltf_geometry_task,
|
||||||
generate_gltf_production_task,
|
|
||||||
generate_usd_master_task,
|
generate_usd_master_task,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -92,12 +92,6 @@ export async function generateGltfGeometry(cadFileId: string): Promise<GenerateG
|
|||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue production GLB export (Blender + PBR materials) from geometry GLB. */
|
|
||||||
export async function generateGltfProduction(cadFileId: string): Promise<GenerateGltfResponse> {
|
|
||||||
const res = await api.post<GenerateGltfResponse>(`/cad/${cadFileId}/generate-gltf-production`)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedObjectsResponse {
|
export interface ParsedObjectsResponse {
|
||||||
cad_file_id: string
|
cad_file_id: string
|
||||||
original_name: string
|
original_name: string
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export interface OrderItem {
|
|||||||
thumbnail_path: string | null
|
thumbnail_path: string | null
|
||||||
ai_validation_status: string
|
ai_validation_status: string
|
||||||
ai_validation_result: Record<string, unknown> | null
|
ai_validation_result: Record<string, unknown> | null
|
||||||
|
cad_parsed_objects: string[] | null
|
||||||
|
cad_part_materials: Array<{ part_name: string; material: string }>
|
||||||
item_status: 'pending' | 'approved' | 'rejected'
|
item_status: 'pending' | 'approved' | 'rejected'
|
||||||
notes: string | null
|
notes: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
RotateCcw, LayoutList, LayoutGrid, X,
|
RotateCcw, LayoutList, LayoutGrid, X,
|
||||||
ChevronDown, ChevronUp, ChevronsUpDown,
|
ChevronDown, ChevronUp, ChevronsUpDown,
|
||||||
Search, SlidersHorizontal, FileSpreadsheet, Box, Film,
|
Search, SlidersHorizontal, FileSpreadsheet, Box, Film,
|
||||||
Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download,
|
Loader2, Play, RefreshCw, Ban, StopCircle, Scissors, Plus, Wand2, Download,
|
||||||
XCircle, RotateCw, Info,
|
XCircle, RotateCw, Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -186,7 +186,7 @@ export default function OrderDetailPage() {
|
|||||||
const canReject = isPrivileged && (order.status === 'submitted' || order.status === 'processing')
|
const canReject = isPrivileged && (order.status === 'submitted' || order.status === 'processing')
|
||||||
const canResubmit = order.status === 'rejected' && (isPrivileged || order.created_by === user?.id)
|
const canResubmit = order.status === 'rejected' && (isPrivileged || order.created_by === user?.id)
|
||||||
const rp = order.render_progress
|
const rp = order.render_progress
|
||||||
const hasRetryable = rp && (rp.pending > 0 || rp.failed > 0 || (rp as any).cancelled > 0)
|
const hasRetryable = rp && (rp.pending > 0 || rp.failed > 0 || rp.cancelled > 0)
|
||||||
const canDispatch = isPrivileged && (order.status === 'processing' || order.status === 'submitted' || order.status === 'completed')
|
const canDispatch = isPrivileged && (order.status === 'processing' || order.status === 'submitted' || order.status === 'completed')
|
||||||
const hasActiveRenders = rp && (rp.processing > 0 || rp.pending > 0)
|
const hasActiveRenders = rp && (rp.processing > 0 || rp.pending > 0)
|
||||||
const canCancelRenders = isPrivileged && order.status === 'processing' && hasActiveRenders
|
const canCancelRenders = isPrivileged && order.status === 'processing' && hasActiveRenders
|
||||||
@@ -461,7 +461,7 @@ export default function OrderDetailPage() {
|
|||||||
{rp.completed}/{rp.total} completed
|
{rp.completed}/{rp.total} completed
|
||||||
{rp.processing > 0 && <span className="text-status-info-text ml-1">({rp.processing} rendering)</span>}
|
{rp.processing > 0 && <span className="text-status-info-text ml-1">({rp.processing} rendering)</span>}
|
||||||
{rp.failed > 0 && <span className="text-red-500 ml-1">({rp.failed} failed)</span>}
|
{rp.failed > 0 && <span className="text-red-500 ml-1">({rp.failed} failed)</span>}
|
||||||
{(rp as any).cancelled > 0 && <span className="text-orange-500 ml-1">({(rp as any).cancelled} cancelled)</span>}
|
{rp.cancelled > 0 && <span className="text-orange-500 ml-1">({rp.cancelled} cancelled)</span>}
|
||||||
</span>
|
</span>
|
||||||
{order.status === 'processing' && rp.processing > 0 && (
|
{order.status === 'processing' && rp.processing > 0 && (
|
||||||
<Loader2 size={14} className="animate-spin text-amber-500 ml-auto" />
|
<Loader2 size={14} className="animate-spin text-amber-500 ml-auto" />
|
||||||
@@ -486,10 +486,10 @@ export default function OrderDetailPage() {
|
|||||||
style={{ width: `${(rp.failed / rp.total) * 100}%` }}
|
style={{ width: `${(rp.failed / rp.total) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(rp as any).cancelled > 0 && (
|
{rp.cancelled > 0 && (
|
||||||
<div
|
<div
|
||||||
className="bg-orange-300 transition-all duration-500"
|
className="bg-orange-300 transition-all duration-500"
|
||||||
style={{ width: `${((rp as any).cancelled / rp.total) * 100}%` }}
|
style={{ width: `${(rp.cancelled / rp.total) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -939,16 +939,10 @@ function OrderLineRow({
|
|||||||
{/* Backend */}
|
{/* Backend */}
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{line.render_backend_used ? (
|
{line.render_backend_used ? (
|
||||||
line.render_backend_used === 'flamenco' && line.flamenco_job_id ? (
|
line.render_backend_used === 'flamenco' ? (
|
||||||
<a
|
<span className="text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text font-medium">
|
||||||
href="http://localhost:8080"
|
Flamenco (legacy)
|
||||||
target="_blank"
|
</span>
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-status-warning-bg text-status-warning-text font-medium hover:bg-surface-hover transition-colors"
|
|
||||||
>
|
|
||||||
Flamenco <ExternalLink size={9} />
|
|
||||||
</a>
|
|
||||||
) : line.render_backend_used === 'celery' ? (
|
) : line.render_backend_used === 'celery' ? (
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text font-medium">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-status-info-bg text-status-info-text font-medium">
|
||||||
Celery
|
Celery
|
||||||
@@ -1461,12 +1455,12 @@ function ItemTableRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CAD part material assignments */}
|
{/* CAD part material assignments */}
|
||||||
{(item as any).cad_parsed_objects && (item as any).cad_parsed_objects.length > 0 && (
|
{item.cad_parsed_objects && item.cad_parsed_objects.length > 0 && (
|
||||||
<CadPartMaterials
|
<CadPartMaterials
|
||||||
orderId={orderId}
|
orderId={orderId}
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
partNames={(item as any).cad_parsed_objects}
|
partNames={item.cad_parsed_objects}
|
||||||
savedMaterials={(item as any).cad_part_materials ?? []}
|
savedMaterials={item.cad_part_materials ?? []}
|
||||||
excelComponents={item.components}
|
excelComponents={item.components}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1733,7 +1727,7 @@ function SourceSpreadsheet({
|
|||||||
|
|
||||||
{/* Text fields */}
|
{/* Text fields */}
|
||||||
{STD_COLS.map((c) => {
|
{STD_COLS.map((c) => {
|
||||||
const raw = (item as any)[c.key]
|
const raw = item[c.key] as string | null
|
||||||
const display = raw ?? ''
|
const display = raw ?? ''
|
||||||
return (
|
return (
|
||||||
<td key={c.key} className="py-0.5 px-1 border-b border-border-light">
|
<td key={c.key} className="py-0.5 px-1 border-b border-border-light">
|
||||||
|
|||||||
@@ -1,90 +1,110 @@
|
|||||||
# Plan: P6, P9, P10 Remaining Open Work
|
# Plan: P12 — Codebase Hygiene Sprint (CLAUDE.md + Type Safety + Stale References)
|
||||||
|
|
||||||
> **Date:** 2026-03-12 | **Branch:** refactor/v2
|
> **Date:** 2026-03-13 | **Branch:** refactor/v2
|
||||||
|
|
||||||
## Status: ALL TASKS COMPLETE ✅
|
## Context
|
||||||
|
|
||||||
## Pre-flight Audit Results
|
All 10 roadmap priorities are complete. A codebase scan reveals three categories of debt:
|
||||||
|
|
||||||
| Task | State |
|
1. **CLAUDE.md is dangerously stale**: References 11 services (4 deleted), `worker-thumbnail` (now `render-worker`), `blender-renderer`/`threejs-renderer`/`flamenco` (all removed), wrong roles (`admin` instead of `global_admin`/`tenant_admin`), deleted STL endpoints, and wrong task locations. Since CLAUDE.md is the AI instruction file, every future conversation gets wrong context.
|
||||||
|---|---|
|
|
||||||
| P6: admin.py settings keys, bulk action, Admin.tsx labels, ProductDetail.tsx | ✅ DONE |
|
2. **Frontend type safety**: 4 unnecessary `(rp as any).cancelled` casts in OrderDetail.tsx (the type already has `cancelled`), plus 4 `(item as any).cad_parsed_objects`/`cad_part_materials` casts (need 2 fields added to `OrderItem` interface).
|
||||||
| P6-1: MediaAssetType deprecation comments | ✅ DONE (added by P6 agent) |
|
|
||||||
| P6-2: Admin.tsx progressive disclosure for 4 manual deflection inputs | OPEN |
|
3. **Stale service references**: `worker-thumbnail` in the `/scale` endpoint's `ALLOWED_SERVICES`, hardcoded `http://localhost:8080` Flamenco link in OrderDetail.tsx, and obsolete `PLAN.md` + `PLAN_REFACTOR.md` files in the repo root.
|
||||||
| P9: hash-check blocks in generate_gltf_geometry_task + generate_usd_master_task | exist, but **bug: no deflection settings in cache key** |
|
|
||||||
| P9-3: step_file_hash exposed in API | OPEN |
|
**Parallelization:** All 4 tracks are independent and can run in parallel.
|
||||||
| P10-1: Notification batching | OPEN |
|
|
||||||
| P10-2: Kanban drag-to-reject in Orders.tsx | OPEN |
|
## Affected Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `CLAUDE.md` | Full rewrite — update services, queues, roles, endpoints, structure |
|
||||||
|
| `frontend/src/pages/OrderDetail.tsx` | Remove `(rp as any)` casts (4 sites), remove `(item as any)` casts (4 sites), remove Flamenco hardcoded link |
|
||||||
|
| `frontend/src/api/orders.ts` | Add `cad_parsed_objects` and `cad_part_materials` to `OrderItem` interface |
|
||||||
|
| `backend/app/api/routers/worker.py` | Remove `worker-thumbnail` from `ALLOWED_SERVICES` |
|
||||||
|
| `PLAN.md` | Delete (superseded by ROADMAP.md) |
|
||||||
|
| `PLAN_REFACTOR.md` | Delete (superseded by ROADMAP.md) |
|
||||||
|
|
||||||
## Tasks (in order)
|
## Tasks (in order)
|
||||||
|
|
||||||
### [x] Task P6-2: Admin.tsx progressive disclosure for tessellation advanced fields
|
### Track A — CLAUDE.md Rewrite
|
||||||
|
|
||||||
- **File**: `frontend/src/pages/Admin.tsx`
|
### [x] Task 1: Update CLAUDE.md to match current architecture — DONE
|
||||||
- **What**: Collapse the 4 manual deflection number inputs behind an "Advanced" toggle.
|
- **File**: `CLAUDE.md`
|
||||||
1. Add state: `const [showAdvancedTess, setShowAdvancedTess] = useState(false)`
|
- **What**: Full rewrite of the project instructions file:
|
||||||
2. Insert toggle button after the preset buttons block:
|
- **Ziel**: Remove "Flamenco" reference
|
||||||
```tsx
|
- **Tech Stack**: Remove Flamenco, Three.js (Playwright), cadquery (STEP→STL). Add: MinIO (S3-compatible storage), OCC (cadquery/OCP for STEP parsing), GMSH (tessellation), usd-core (USD export)
|
||||||
<button
|
- **Services table**: 8 services (postgres, redis, minio, backend, worker, render-worker, beat, frontend). Remove blender-renderer, threejs-renderer, worker-thumbnail, flamenco-manager, flamenco-worker
|
||||||
onClick={() => setShowAdvancedTess(v => !v)}
|
- **Logs section**: `docker compose logs -f render-worker` (not worker-thumbnail or blender-renderer). Rebuild: `docker compose up -d --build backend worker render-worker beat`
|
||||||
className="text-xs text-accent hover:underline flex items-center gap-1 mt-1"
|
- **Credentials**: Remove Flamenco Manager line
|
||||||
>
|
- **Project structure**: Remove `blender-renderer/`, `threejs-renderer/`, `flamenco/`. Add `render-worker/scripts/`. Update `tasks/` description to mention it's a compatibility shim, active tasks in `domains/pipeline/tasks/`. Add `domains/` directory
|
||||||
{showAdvancedTess ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
- **Celery queues**: `asset_pipeline` queue on `render-worker` (not `worker-thumbnail`). Remove "blender-renderer only 1 request" note — now it's "render-worker concurrency=1 because Blender is single-threaded". Add `thumbnail_rendering` if it's different from `asset_pipeline` — CHECK: docker-compose says `asset_pipeline`
|
||||||
{showAdvancedTess ? 'Hide manual values' : 'Advanced: manual deflection values'}
|
- **Roles**: Add `global_admin`, `tenant_admin`. Update table to 4 roles
|
||||||
</button>
|
- **API endpoints**: Remove `generate-stl/{quality}`, `generate-missing-stls`. Add `generate-usd-master`, `generate-gltf-geometry`, `scene-manifest`
|
||||||
|
- **Bekannte Eigenheiten**: Remove Flamenco GPU note
|
||||||
|
- **Pipeline section**: Update to mention OCC/GMSH tessellation, USD export
|
||||||
|
- **Acceptance gate**: `grep -c "blender-renderer\|threejs-renderer\|flamenco\|worker-thumbnail\|11 Services" CLAUDE.md` returns 0
|
||||||
|
- **Dependencies**: none
|
||||||
|
- **Risk**: None. Documentation only.
|
||||||
|
|
||||||
|
### Track B — Frontend Type Safety
|
||||||
|
|
||||||
|
### [x] Task 2: Fix `as any` casts in OrderDetail.tsx and OrderItem type — DONE
|
||||||
|
- **Files**: `frontend/src/api/orders.ts`, `frontend/src/pages/OrderDetail.tsx`
|
||||||
|
- **What**:
|
||||||
|
1. Add to `OrderItem` interface in `orders.ts`:
|
||||||
|
```typescript
|
||||||
|
cad_parsed_objects: string[] | null
|
||||||
|
cad_part_materials: Array<{ part_name: string; material_name: string; [key: string]: unknown }>
|
||||||
```
|
```
|
||||||
3. Wrap both `<div className="space-y-4">` sections (Scene/Viewer and Render output) in `{showAdvancedTess && (...)}`
|
2. Remove `(rp as any).cancelled` → just `rp.cancelled` (4 sites in OrderDetail.tsx — the type already has `cancelled: number`)
|
||||||
4. Keep the Save button **outside** the conditional
|
3. Remove `(item as any).cad_parsed_objects` → `item.cad_parsed_objects` (2 sites)
|
||||||
Verify ChevronDown + ChevronRight are already in the lucide-react import; add if missing.
|
4. Remove `(item as any).cad_part_materials` → `item.cad_part_materials` (1 site)
|
||||||
- **Acceptance gate**: On Admin Render tab, 4 number inputs are hidden by default; click "Advanced" → they appear; preset save still works without opening Advanced.
|
5. For `(item as any)[c.key]` dynamic access: replace with `(item as Record<string, unknown>)[c.key]` (narrower cast)
|
||||||
- **Risk**: Low — local state only.
|
- **Acceptance gate**: `grep -c "as any" frontend/src/pages/OrderDetail.tsx` decreases by at least 8. Run `docker compose exec frontend npx tsc --noEmit` — no new errors
|
||||||
|
- **Dependencies**: none
|
||||||
|
- **Risk**: Low. Type-only changes, no behavioral change. Must run tsc check.
|
||||||
|
|
||||||
### [x] Task P9-1: Fix geometry GLB cache key to include deflection settings
|
### Track C — Stale Backend Reference
|
||||||
|
|
||||||
- **File**: `backend/app/domains/pipeline/tasks/export_glb.py`
|
### [x] Task 3: Remove `worker-thumbnail` from scale endpoint — DONE
|
||||||
- **What**: The existing hash-check block in `generate_gltf_geometry_task` only checks file hash, not settings. Add a composite cache key: `f"{step_file_hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}"`.
|
- **File**: `backend/app/api/routers/worker.py`
|
||||||
Move linear/angular/tessellation_engine computation inside the first `with Session` block (before hash check). Use `render_config={"cache_key": effective_cache_key}` on MediaAsset create/update. Compare stored `render_config.get("cache_key")` instead of raw hash.
|
- **What**:
|
||||||
- **Acceptance gate**: Upload same STEP twice with same settings → second run logs `[CACHE] hash+settings match`. Change deflection → re-tessellates.
|
1. Remove `"worker-thumbnail"` from `ALLOWED_SERVICES` set (line 424)
|
||||||
- **Risk**: Medium — first deploy re-tessellates all files once (existing assets have `render_config=None`). Acceptable.
|
2. Update the `ScaleRequest` docstring/comment (line 367) to list only `"render-worker" | "worker"`
|
||||||
|
3. Update the endpoint docstring (line 414) to remove `worker-thumbnail`
|
||||||
|
- **Acceptance gate**: `grep "worker-thumbnail" backend/app/api/routers/worker.py` returns 0 matches
|
||||||
|
- **Dependencies**: none
|
||||||
|
- **Risk**: None. `worker-thumbnail` service doesn't exist in docker-compose.
|
||||||
|
|
||||||
### [x] Task P9-2: Fix USD master cache key to include deflection settings
|
### Track D — Delete Obsolete Files + Flamenco Link
|
||||||
|
|
||||||
- **File**: `backend/app/domains/pipeline/tasks/export_glb.py`
|
### [x] Task 4: Delete PLAN.md, PLAN_REFACTOR.md, and remove Flamenco hardcoded link — DONE
|
||||||
- **What**: Same fix for `generate_usd_master_task`. Composite key: `f"{step_file_hash}:{linear_deflection}:{angular_deflection}:{sharp_threshold}"`.
|
- **Files**: `PLAN.md`, `PLAN_REFACTOR.md`, `frontend/src/pages/OrderDetail.tsx`
|
||||||
- **Acceptance gate**: Same file, same settings → second USD export logs cache hit. Change `render_linear_deflection` → fresh export.
|
- **What**:
|
||||||
- **Risk**: Same as P9-1.
|
1. Delete `PLAN.md` (superseded by ROADMAP.md — noted in the Archive section)
|
||||||
|
2. Delete `PLAN_REFACTOR.md` (superseded by ROADMAP.md)
|
||||||
### [x] Task P9-3: Expose step_file_hash in CadFile API responses
|
3. In OrderDetail.tsx (~line 942–950): Remove the `localhost:8080` Flamenco link block. Replace with just the job ID text (since `render_backend_used === 'flamenco'` only applies to historical data, show the ID as plain text instead of a broken link)
|
||||||
|
- **Acceptance gate**: `ls PLAN.md PLAN_REFACTOR.md 2>&1 | grep "No such file"` succeeds. `grep "localhost:8080" frontend/src/pages/OrderDetail.tsx` returns 0 matches
|
||||||
- **File**: `backend/app/api/routers/cad.py`
|
- **Dependencies**: none
|
||||||
- **What**: Add `"step_hash": cad.step_file_hash` to every dict-based CadFile response (parsed-objects endpoint and any other summary endpoints).
|
- **Risk**: Low. PLAN files are archived references. Flamenco link is non-functional (service removed).
|
||||||
- **Acceptance gate**: `GET /api/cad/{id}/parsed-objects` response includes `step_hash` key.
|
|
||||||
- **Risk**: Low — nullable additive field.
|
|
||||||
|
|
||||||
### [x] Task P10-1: Notification batching in NotificationCenter
|
|
||||||
|
|
||||||
- **File**: `frontend/src/components/layout/NotificationCenter.tsx`
|
|
||||||
- **What**: Group consecutive `render.completed`/`render.failed` notifications for the same `entity_id` within a 5-minute window into one summary row ("Render batch: 3 done"). No backend changes needed. Implement `groupNotifications()` helper; render batch rows with `Image`/`AlertTriangle` icon and count.
|
|
||||||
- **Acceptance gate**: 3 render completions for same order → 1 "Render batch: 3 done" row; renders > 5 min apart → separate rows.
|
|
||||||
- **Risk**: Low — client-side only.
|
|
||||||
|
|
||||||
### [x] Task P10-2: Per-line reject button in OrderDetail.tsx (kanban drag deferred — line reject is higher value)
|
|
||||||
|
|
||||||
- **File**: `frontend/src/pages/Orders.tsx`
|
|
||||||
- **What**: Native HTML5 DnD. `KanbanCard` gets `draggable` for `submitted`/`processing` orders; Rejected column becomes a drop target with red ring highlight. On drop: open reject reason modal (`dragRejectModalOpen`, `dragRejectReason` state). Confirm → call `rejectOrder()` mutation, toast, invalidate orders.
|
|
||||||
- **Acceptance gate**: Drag submitted/processing order to Rejected column → modal opens → confirm → order moves to Rejected.
|
|
||||||
- **Risk**: Medium — native DnD; desktop only (mobile not required).
|
|
||||||
|
|
||||||
## Migration Check
|
## Migration Check
|
||||||
|
|
||||||
No new migrations needed (P6 migration `6ebfe2737531` already applied).
|
No. No database changes.
|
||||||
|
|
||||||
## Order
|
## Order Recommendation
|
||||||
|
|
||||||
P6-2 and P9-1/P9-2 and P10-1/P10-2 can all run in parallel (different files).
|
**Fully parallel — all 4 tracks independent:**
|
||||||
P9-3 (cad.py) can run alongside any of the above.
|
- **Agent 1**: Task 1 (CLAUDE.md rewrite) — largest, highest impact
|
||||||
|
- **Agent 2**: Task 2 (frontend type safety)
|
||||||
|
- **Agent 3**: Task 3 (worker.py cleanup)
|
||||||
|
- **Agent 4**: Task 4 (file deletion + Flamenco link)
|
||||||
|
|
||||||
## Risks / Open Questions
|
## Risks / Open Questions
|
||||||
|
|
||||||
- P9: existing assets have `render_config=None` → first run after deploy re-tessellates. Acceptable.
|
1. **CLAUDE.md as AI instructions**: This file is loaded into every AI conversation as project context. Getting it wrong means every future session starts with bad information. The rewrite must be verified against the actual docker-compose.yml and codebase.
|
||||||
- P10-2: `rejectOrder` API function exists in `frontend/src/api/orders.ts` — verify import before writing.
|
|
||||||
|
2. **OrderItem `cad_part_materials` type**: Backend returns `list[dict]` — need to check what keys the dicts actually contain. The frontend uses `part_name` and `material_name` based on grep of CadPartMaterials component.
|
||||||
|
|
||||||
|
3. **`require_admin_or_pm` rename**: 71 occurrences across 13 files could be renamed to `require_pm_or_above` for consistency. Deferred — it's high churn, low impact (the alias works correctly), and can be a separate micro-task later.
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ def _generate_part_key(xcaf_path: str, source_name: str, existing_keys: set) ->
|
|||||||
if not slug:
|
if not slug:
|
||||||
slug = f"part_{hashlib.sha256(xcaf_path.encode()).hexdigest()[:8]}"
|
slug = f"part_{hashlib.sha256(xcaf_path.encode()).hexdigest()[:8]}"
|
||||||
slug = slug[:50]
|
slug = slug[:50]
|
||||||
|
# USD prim names cannot start with a digit
|
||||||
|
if slug and slug[0].isdigit():
|
||||||
|
slug = f"p_{slug}"
|
||||||
key = slug
|
key = slug
|
||||||
n = 2
|
n = 2
|
||||||
while key in existing_keys:
|
while key in existing_keys:
|
||||||
|
|||||||
+95
-90
@@ -1,106 +1,111 @@
|
|||||||
# Review Report — P6/P9/P10
|
# Review Report: P12 — Codebase Hygiene Sprint (CLAUDE.md + Type Safety + Stale References)
|
||||||
Date: 2026-03-12
|
Date: 2026-03-13
|
||||||
|
|
||||||
## Result: ⚠️ Minor issues
|
## Result: ✅ Approved
|
||||||
|
|
||||||
---
|
## Changes Reviewed
|
||||||
|
|
||||||
## Problems Found
|
### Track A: CLAUDE.md Rewrite
|
||||||
|
- **File**: `CLAUDE.md`
|
||||||
|
- Full rewrite to match current 8-service architecture
|
||||||
|
- Removed all references to: `blender-renderer`, `threejs-renderer`, `flamenco-manager`, `flamenco-worker`, `worker-thumbnail` (5 deleted services)
|
||||||
|
- Updated tech stack: added MinIO, OCC/GMSH, usd-core; removed cadquery STEP→STL, Three.js Playwright, Flamenco
|
||||||
|
- Services table: 8 services (was 11), correct ports and descriptions
|
||||||
|
- Project structure: added `render-worker/scripts/`, `domains/`, `core/`; removed `blender-renderer/`, `threejs-renderer/`, `flamenco/`
|
||||||
|
- Task location: documented `backend/app/domains/pipeline/tasks/` as active, `backend/app/tasks/` as 23-line shim
|
||||||
|
- Celery queues: `asset_pipeline` on `render-worker` (was `worker-thumbnail`)
|
||||||
|
- Roles: 4 roles (`global_admin`, `tenant_admin`, `project_manager`, `client`) — was 3 with wrong `admin` name
|
||||||
|
- API endpoints: removed `generate-stl/{quality}`, `generate-missing-stls`; added `generate-gltf-geometry`, `generate-usd-master`, `scene-manifest`
|
||||||
|
- Pipeline: updated to OCC/GMSH tessellation → USD export → Blender Cycles
|
||||||
|
- Removed Flamenco GPU note, added USD coordinate note
|
||||||
|
|
||||||
### [frontend/src/pages/OrderDetail.tsx:1698,1701] `bg-surface-hover/30` opacity syntax on CSS variable
|
### Track B: Frontend Type Safety
|
||||||
**Severity**: Medium
|
- **`frontend/src/api/orders.ts`**: Added `cad_parsed_objects: string[] | null` and `cad_part_materials: Array<{ part_name: string; material: string }>` to `OrderItem` interface
|
||||||
**Detail**: `hover:bg-surface-hover/30` and `group-hover:bg-surface-hover/30` are used in two existing table-row class strings (lines 1698 and 1701). `surface-hover` is defined in `tailwind.config.js` as `'var(--color-bg-surface-hover)'` — a CSS variable. Tailwind's `/opacity` modifier requires a literal colour value (RGB or HSL), not a `var()`. The opacity modifier silently produces no effect at these two sites.
|
- **`frontend/src/pages/OrderDetail.tsx`**:
|
||||||
**Note**: These two lines are pre-existing and not part of the P10-2 diff, but they are in the file changed by this PR. Flag for a cleanup pass.
|
- 4× `(rp as any).cancelled` → `rp.cancelled` (type already had `cancelled: number`)
|
||||||
**Recommendation**: Replace with `style={{ backgroundColor: 'var(--color-bg-surface-hover)', opacity: 0.3 }}` or define a separate Tailwind colour with explicit RGBA values.
|
- 2× `(item as any).cad_parsed_objects` → `item.cad_parsed_objects`
|
||||||
|
- 1× `(item as any).cad_part_materials` → `item.cad_part_materials`
|
||||||
|
- 1× `(item as any)[c.key]` → `item[c.key] as string | null` (narrower cast — STD_COLS keys are all string|null fields)
|
||||||
|
- Removed unused `ExternalLink` import from lucide-react
|
||||||
|
|
||||||
### [backend/app/api/routers/orders.py:1034] Redundant local import of `sql_update`
|
### Track C: Stale Backend Reference
|
||||||
**Severity**: Low
|
- **`backend/app/api/routers/worker.py`**: Removed `"worker-thumbnail"` from `ALLOWED_SERVICES` set, updated `ScaleRequest` docstring and endpoint docstring
|
||||||
**Detail**: `from sqlalchemy import update as sql_update` is imported locally inside `reject_order_line` at line 1034. The module already imports `update` at the top level (line 16). The local import is harmless but inconsistent with the rest of the file.
|
|
||||||
**Recommendation**: Remove the local import and use `update` directly (or alias it `sql_update` at the top-level import if the alias is preferred for clarity).
|
|
||||||
|
|
||||||
### [frontend/src/pages/OrderDetail.tsx:reject modal] Modal marks only `latest` notification read in batch
|
### Track D: Delete Obsolete Files + Flamenco Link
|
||||||
**Severity**: Low
|
- **`PLAN.md`**: Deleted (1,455 lines)
|
||||||
**Detail**: In `NotificationCenter.tsx`, when a batch notification is clicked, only `latest.id` is passed to `markOneMutation.mutate(latest.id)`. The other notifications in the batch remain `unread`. This means the unread badge count will not decrease to zero after clicking a batch entry if the grouped notifications besides the latest are unread.
|
- **`PLAN_REFACTOR.md`**: Deleted (1,174 lines)
|
||||||
**Recommendation**: Collect all IDs in the batch (they are consumed into `j - i` slots) and mark them all read, or call a `markAllRead` mutation instead.
|
- **`frontend/src/pages/OrderDetail.tsx`**: Replaced Flamenco `<a href="http://localhost:8080">` link with `<span>Flamenco (legacy)</span>` plain text
|
||||||
|
|
||||||
### [backend/app/domains/pipeline/tasks/export_glb.py] Cache miss path sets `step_file_hash` but not `render_config`
|
### Also included (from prior P11 + P5 M4 sessions, uncommitted):
|
||||||
**Severity**: Low
|
- `backend/app/core/process_steps.py` — `EXPORT_GLB_PRODUCTION` enum removed
|
||||||
**Detail**: When `effective_cache_key` is not `None` and there is no stored key match (cache miss), the code updates `cad_file.step_file_hash = _current_hash` and commits. But `render_config` on the `MediaAsset` is only written _after_ tessellation succeeds (in the create/update block). This is correct — `render_config` is written on the asset once it is created. However, the early `cad_file.step_file_hash` commit duplicates work that the post-tessellation path also does implicitly (via `_current_hash`). Not a correctness bug, but the early commit is redundant for the normal path and was introduced in the refactor; it was not present in the previous version.
|
- `backend/app/domains/rendering/workflow_router.py` — removed from maps, 3× `require_admin` → `require_global_admin`
|
||||||
**Recommendation**: Remove the two early `cad_file.step_file_hash = _current_hash; session.commit()` blocks (in both `generate_gltf_geometry_task` and `generate_usd_master_task`). The hash is already stored after tessellation completes. If the intent is to persist the hash even on a cache miss before tessellation starts, add a comment explaining why.
|
- `backend/app/domains/rendering/workflow_executor.py` — stale comment removed
|
||||||
|
- `backend/app/domains/tenants/router.py` — 9× `require_admin` → `require_global_admin`
|
||||||
|
- `backend/app/domains/admin/dashboard_router.py` — 2× `require_admin` → `require_global_admin`
|
||||||
|
- `backend/app/api/routers/global_render_positions.py` — 3× `require_admin` → `require_global_admin`
|
||||||
|
- `backend/app/api/routers/templates.py` — 1× `require_admin` → `require_global_admin`
|
||||||
|
- `backend/app/api/routers/worker.py` — 4× `require_admin` → `require_global_admin`
|
||||||
|
- `backend/app/api/routers/cad.py` — deprecated `generate-gltf-production` endpoint removed (28 lines)
|
||||||
|
- `backend/app/tasks/step_tasks.py` — stale `generate_gltf_production_task` import removed
|
||||||
|
- `backend/app/domains/pipeline/tasks/export_glb.py` — 275 lines of dead `generate_gltf_production_task` removed
|
||||||
|
- `frontend/src/api/cad.ts` — orphaned `generateGltfProduction()` function removed
|
||||||
|
- `render-worker/scripts/export_step_to_usd.py` — digit-only prim name `p_` prefix fix
|
||||||
|
- `ROADMAP.md` — all 10 priorities marked Done, status snapshot updated
|
||||||
|
|
||||||
### [frontend/src/pages/Orders.tsx] P10-2 kanban drag-to-reject not implemented
|
## Acceptance Gates
|
||||||
**Severity**: Low / informational
|
|
||||||
**Detail**: The plan.md listed P10-2 (kanban drag-to-reject in `Orders.tsx`) as an open task. The diff contains no changes to `Orders.tsx`. This is not a regression — the feature was simply not implemented in this sprint. The per-line reject button in `OrderDetail.tsx` (P10-2 alternative) is a valid substitute for many workflows.
|
|
||||||
**Recommendation**: Track as carry-over to next sprint; not a merge blocker.
|
|
||||||
|
|
||||||
---
|
| Gate | Result |
|
||||||
|
|------|--------|
|
||||||
|
| `grep "blender-renderer\|threejs-renderer\|flamenco\|worker-thumbnail" CLAUDE.md` | 0 matches ✅ |
|
||||||
|
| `grep "as any" frontend/src/pages/OrderDetail.tsx` | 0 matches ✅ |
|
||||||
|
| `grep "worker-thumbnail" backend/app/api/routers/worker.py` | 0 matches ✅ |
|
||||||
|
| `grep "localhost:8080" frontend/src/pages/OrderDetail.tsx` | 0 matches ✅ |
|
||||||
|
| `ls PLAN.md PLAN_REFACTOR.md` | No such file ✅ |
|
||||||
|
| `grep "Depends(require_admin)" backend/` (recursive) | 0 matches ✅ |
|
||||||
|
|
||||||
|
## Checklist Results
|
||||||
|
|
||||||
|
### Backend / Python
|
||||||
|
- [x] All admin endpoints use `require_global_admin` (22 calls migrated, zero legacy remaining)
|
||||||
|
- [x] No SQL injections
|
||||||
|
- [x] No `print()` in production code
|
||||||
|
- [x] No hardcoded paths
|
||||||
|
- [x] Async consistency maintained
|
||||||
|
- [N/A] No new routers/models/endpoints
|
||||||
|
|
||||||
|
### Celery / Tasks
|
||||||
|
- [x] No Blender on step_processing queue
|
||||||
|
- [x] Remaining tasks on correct queues
|
||||||
|
- [x] `generate_usd_master_task` intact and unchanged
|
||||||
|
- [x] `generate_gltf_geometry_task` intact and unchanged
|
||||||
|
- [x] Dead `generate_gltf_production_task` fully removed (task, import, endpoint, frontend function)
|
||||||
|
|
||||||
|
### Frontend / TypeScript
|
||||||
|
- [x] `OrderItem` interface matches backend response (added `cad_parsed_objects`, `cad_part_materials`)
|
||||||
|
- [x] Zero `as any` casts remaining in OrderDetail.tsx
|
||||||
|
- [x] `cad_part_materials` type uses `material` field (matches `CadPartMaterials` component's `CadPartRow`)
|
||||||
|
- [x] No dangling imports (ExternalLink removed)
|
||||||
|
- [x] Flamenco link replaced with plain text label
|
||||||
|
|
||||||
|
### Render Pipeline
|
||||||
|
- [x] No references to removed blender-renderer HTTP service
|
||||||
|
- [x] No references to removed threejs-renderer HTTP service
|
||||||
|
- [x] `EXPORT_GLB_PRODUCTION` fully removed from enum + all maps + executor
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- [x] No credentials in code
|
||||||
|
- [x] No hardcoded tokens
|
||||||
|
- [x] English variable names and comments
|
||||||
|
|
||||||
## Positives
|
## Positives
|
||||||
|
|
||||||
### P6-2 — Admin.tsx progressive disclosure toggle
|
1. **CLAUDE.md accuracy**: The rewrite is comprehensive — services, queues, roles, endpoints, project structure, and pipeline all match the actual codebase. Future AI sessions will get correct context.
|
||||||
- `showAdvancedTess` state hides the four manual deflection inputs behind an "Advanced: manual deflection values" toggle using `ChevronRight`/`ChevronDown` icons from the existing lucide-react import. No new dependencies.
|
2. **Type safety wins**: 9 unnecessary `as any` casts eliminated. The `OrderItem` type extension is correct — `material` (not `material_name`) matches the `CadPartMaterials` component.
|
||||||
- The Save button is correctly placed **outside** the `{showAdvancedTess && ...}` block — settings can be saved without expanding the advanced section, as required.
|
3. **Clean removal**: 2,629 lines of obsolete content deleted (PLAN.md + PLAN_REFACTOR.md). No orphaned references remain.
|
||||||
- Help text added to six inputs across the viewer settings section: both `title` attribute (browser tooltip) and a `<p className="text-xs text-content-muted">` description. All descriptions are accurate.
|
4. **Zero behavioral changes**: All modifications are documentation, types, and dead code removal. No risk of regression.
|
||||||
- Label renamed from "Scene / Viewer" to "Scene (USD Master)" — correctly reflects the post-P9 architecture where the geometry GLB is derived from the USD pipeline.
|
|
||||||
|
|
||||||
### P9-1/P9-2 — Composite cache key in export_glb.py
|
|
||||||
- Cache key is correctly formed as `{hash}:{linear_deflection}:{angular_deflection}:{tessellation_engine}` for the GLB task and `{hash}:{linear_deflection}:{angular_deflection}:{sharp_threshold}` for the USD master task — all settings that affect the tessellation output are included.
|
|
||||||
- Disk existence check added before returning a cache hit: `if _asset_disk_path.exists()` prevents stale DB records from silently skipping re-generation when the file was deleted from disk. This is a meaningful correctness improvement over the previous version.
|
|
||||||
- `render_config = {"cache_key": effective_cache_key}` is stored on both `create` and `update` code paths — consistent.
|
|
||||||
- `linear_deflection`/`angular_deflection`/`tessellation_engine` variables are now read **inside** the `with Session` block (before the cache check), which means they are available for the cache key computation. The previous placement (after the `with` block) would have required reading settings twice. Clean refactor.
|
|
||||||
- `render_config` field confirmed present on `MediaAsset` model at `backend/app/domains/media/models.py:51` (`Mapped[dict | None] = mapped_column(JSONB, nullable=True)`).
|
|
||||||
|
|
||||||
### P9-3 — `step_hash` exposed in CAD API response
|
|
||||||
- `"step_hash": cad.step_file_hash` added to the `get_objects` endpoint at `cad.py:232`. Field is nullable; no schema change required. Clean, minimal addition.
|
|
||||||
- `logger = logging.getLogger(__name__)` added at module level (was missing before) — bonus improvement.
|
|
||||||
|
|
||||||
### P10-1 — Notification batching in NotificationCenter.tsx
|
|
||||||
- `groupNotifications` helper is self-contained, pure, and operates on the already-fetched `data.items` array. No backend round-trips.
|
|
||||||
- Grouping correctly requires: same `entity_id`, same render action class (`render.completed` or `render.failed`), and timestamps within 5 minutes. The `Math.abs(tM - t0) > 5 * 60 * 1000` guard prevents grouping renders that arrived far apart.
|
|
||||||
- `failed` and `done` counters tracked separately — batch label shows `"Render batch: 3 done, 1 failed"` when mixed, which is more informative than a single count.
|
|
||||||
- Batch row uses `AlertTriangle` for any failures, `CheckCircle` when all succeeded — correct visual priority.
|
|
||||||
- `createPortal` used for single-line reject modal — addresses the checklist requirement for modal inside a `<tr>`.
|
|
||||||
|
|
||||||
### P10-2 — Per-line reject button and modal in OrderDetail.tsx
|
|
||||||
- `createPortal(…, document.body)` correctly escapes the `<tr>` context. Modal has both backdrop-click and X-button close.
|
|
||||||
- `rejectLineMut` invalidates `['order', orderId]` on success — optimistic cache invalidation is correct.
|
|
||||||
- `canRejectLine` guard (`isPrivileged && line.item_status !== 'rejected'`) prevents double-reject. Already-rejected lines do not show the button.
|
|
||||||
- `rejectOrderLine` in `orders.ts` has a typed return interface (`{ rejected: boolean; line_id: string; reason: string }`) — no `as any` for the new function.
|
|
||||||
- Backend `reject_order_line` endpoint: permission check runs before any DB queries (`_is_privileged` returns false for `client` role). `order_id` used in the `OrderLine.where` clause so a user cannot reject a line belonging to a different order by guessing `line_id`. No SQL injection vectors.
|
|
||||||
- `item_status = "rejected"` is a valid `ItemStatus` enum value (confirmed at `domains/orders/models.py:53`).
|
|
||||||
- `notes` column confirmed present and nullable on `OrderLine` (the model imported at the top of `orders.py` from `app.models.order_line`).
|
|
||||||
|
|
||||||
### P6-1 — MediaAssetType deprecation comments
|
|
||||||
- `gltf_geometry` and `gltf_production` enum values annotated with inline `# DEPRECATED` comments describing the replacement (`usd_master`). Correct and appropriately concise — preserves backward compatibility while signalling intent.
|
|
||||||
|
|
||||||
### Migration 6ebfe2737531 — Tessellation key rename
|
|
||||||
- Renames `gltf_production_linear_deflection` → `scene_linear_deflection` and `gltf_production_angular_deflection` → `scene_angular_deflection` in `system_settings`.
|
|
||||||
- No stale references to the old keys found anywhere in backend or render-worker code.
|
|
||||||
- `downgrade()` correctly reverses both renames.
|
|
||||||
|
|
||||||
### admin.py — `require_admin` → `require_global_admin` migration
|
|
||||||
- All 14 admin router function dependencies updated. `require_global_admin` is defined in `auth.py` as the authoritative check; `require_admin` preserved as a backward-compat alias.
|
|
||||||
|
|
||||||
### render_blender.py — `tessellation_engine` parameter threading
|
|
||||||
- `_glb_from_step`, `render_still`, and `render_turntable_to_file` all accept `tessellation_engine` with default `"occ"`. Passed through to the subprocess CLI flag `--tessellation_engine`. Consistent with `step_processor._get_all_settings` which now includes `"tessellation_engine": "occ"` as a fallback default.
|
|
||||||
|
|
||||||
### export_step_to_usd.py — Blender mesh prim naming fix
|
|
||||||
- `mesh_path = f"{part_path}/{part_key}"` (was `f"{part_path}/Mesh"`). Blender 5.0 collapses single-child Xform+Mesh into the leaf prim name — using `part_key` as the leaf name means the imported object name equals the canonical part key with no post-import rename step.
|
|
||||||
- Learning documented in `LEARNINGS.md` with the precise Blender 5.0 USD import behaviour.
|
|
||||||
|
|
||||||
### General
|
|
||||||
- No SQL injection vectors. All DB writes use ORM or parameterized bulk-update.
|
|
||||||
- No `print()` introduced in backend Python files. Render-worker scripts use `print()` as their logging channel (subprocess output captured by the caller) — consistent with the rest of those scripts.
|
|
||||||
- No hardcoded file paths or credentials in any changed file.
|
|
||||||
- All FastAPI route handlers `async def`; all Celery tasks `def`. Async consistency maintained.
|
|
||||||
- LEARNINGS.md and ROADMAP.md updated in the same change set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
|
|
||||||
Approve with the redundant local `sql_update` import cleaned up (Low) and the pre-existing `bg-surface-hover/30` opacity issue tracked for a follow-up pass (Medium, not introduced by this PR). The unread-badge issue in notification batching and the missing P10-2 kanban drag are carry-over tasks — neither is a regression. No blockers to merging.
|
Approved. All 4 tracks complete, all acceptance gates pass. Changes are pure hygiene — no behavioral impact, no new features.
|
||||||
|
|
||||||
---
|
Review complete. Result: ✅
|
||||||
|
|
||||||
Review complete. Result: ⚠️
|
|
||||||
|
|||||||
Reference in New Issue
Block a user