fix(render): production GLB sharp edges + materials (25/25)
Sharp edges: - OCC→Blender coordinate transform was wrong: Blender(X,Y,Z) = OCC(X×0.001, -Z×0.001, Y×0.001) (RWGltf Z→Y-up + Blender Y→Z-up cancel to Y↔Z swap with Z negated) - Guard against degenerate edges (idx0==idx1) to prevent bmesh ValueError - Use obj.matrix_world @ v.co for world-space KD-tree (assembly node transforms) Materials: - Single-material fallback: if only 1 library material loaded, apply to all unmatched objects (cadquery vs RWGltf part names differ, name-match covers ~2/25) - Fix mesh data sharing: obj.data.copy() before materials.clear() to avoid clearing shared data blocks - Use obj.name (not id(obj)) for cross-loop tracking — Blender Python wrappers can be recreated between iterations Both fixes applied to export_gltf.py (production GLB) and blender_render.py (thumbnails). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+343
-460
@@ -7,289 +7,395 @@
|
|||||||
|
|
||||||
## Learnings
|
## Learnings
|
||||||
|
|
||||||
### 2026-03-07 | Security | Media-Endpoints ohne Auth — Tenant-RLS reicht nicht allein
|
|
||||||
**Problem**: `list_assets`, `download_asset`, `zip_download` hatten kein `get_current_user`-Dep → unauthentifizierte Requests möglich. RLS schützt nur Datenbankzugriffe, nicht HTTP-Ebene.
|
|
||||||
**Lösung**: `_user: User = Depends(get_current_user)` zu allen drei Endpoints hinzufügen. RLS filtert dann automatisch per Tenant-ID aus dem JWT-Token (via Session-Variable `app.current_tenant_id`).
|
|
||||||
**Für künftige Projekte**: Jeder neue Router-Endpoint braucht expliziten Auth-Dep — RLS ist Defense-in-Depth, kein Ersatz für HTTP-Auth.
|
|
||||||
|
|
||||||
### 2026-03-07 | MediaAsset | `is_animation` Flag entscheidet Asset-Type — falsches Design
|
|
||||||
**Problem**: `import_existing_media_assets` + `render_order_line_task` nutzten `output_type.is_animation == True` um `asset_type = turntable` zu setzen — auch für `.jpg` Poster-Frames aus Animations-OutputTypes. Folge: 6 JPG-Assets als `turntable` in DB → Broken-Video-Icons in MediaBrowser.
|
|
||||||
**Lösung**: Extension entscheidet: `.mp4`/`.webm` → `turntable`, alles andere → `still`. `is_animation` Flag ist für OutputType-Konfiguration, nicht für Asset-Klassifizierung.
|
|
||||||
**Für künftige Projekte**: MIME-Typ/Extension immer als primäre Typ-Quelle, niemals Meta-Flags des Auftrags.
|
|
||||||
|
|
||||||
### 2026-03-07 | OCC | Bounding-Box aus STEP mit `Bnd_Box` + `brepbndlib.Add()`
|
|
||||||
**Problem**: Keine Real-World-Dimensions in der DB — weder Breite/Höhe/Tiefe noch Bauteil-Mittelpunkt. OCC-Extraktion lieferte nur Kanten-Topologie.
|
|
||||||
**Lösung**: `from OCC.Core.Bnd import Bnd_Box; from OCC.Core.BRepBndLib import brepbndlib; bbox = Bnd_Box(); brepbndlib.Add(shape, bbox); xmin,ymin,zmin,xmax,ymax,zmax = bbox.Get()` → `dimensions_mm = {x, y, z}` in `mesh_attributes` JSONB. Kein neues DB-Feld nötig — JSONB-Erweiterung reicht.
|
|
||||||
**Für künftige Projekte**: OCC `Bnd_Box` gibt Werte in mm (STEP-Einheit). In Blender nach Scale-Apply (0.001) sind die Werte dann in m.
|
|
||||||
|
|
||||||
### 2026-03-07 | Storage | `storage_key` absolute Pfade brachen Volume-Moves
|
|
||||||
**Problem**: `step_tasks.py` und `admin.py` schrieben `storage_key=str(output_path)` mit absoluten Pfaden (`/shared/data/uploads/...`). Nach Volume-Umzug in v2 waren 398 Assets nicht mehr erreichbar.
|
|
||||||
**Lösung**: `_normalize_key()` Helper: strippt `UPLOAD_DIR`-Prefix. In `download_asset` Legacy-Remapping für alte Pfade als Fallback behalten. Neue Assets immer relativ speichern.
|
|
||||||
**Für künftige Projekte**: `storage_key` immer relativ zu `UPLOAD_DIR` → `candidate = Path(settings.upload_dir) / key`. Absolute Pfade nie in die DB schreiben.
|
|
||||||
|
|
||||||
### 2026-03-07 | OCP | `RWMesh_CoordinateSystemConverter` nicht als Python-Binding verfügbar (OCP 7.8.1.1)
|
|
||||||
**Problem**: `writer.ChangeCoordinateSystemConverter()` wirft `TypeError: Unregistered type : RWMesh_CoordinateSystemConverter` — der C++-Typ ist nicht in OCP-Python-Bindings registriert.
|
|
||||||
**Lösung**: Shapes vor dem Export mit `BRepBuilderAPI_Transform` um Faktor 0.001 skalieren (mm→m). Dann `RWGltf_CafWriter` direkt ohne Koordinatensystem-Konverter aufrufen.
|
|
||||||
```python
|
|
||||||
trsf = gp_Trsf()
|
|
||||||
trsf.SetScaleFactor(0.001)
|
|
||||||
for i in range(1, free_labels.Length() + 1):
|
|
||||||
label = free_labels.Value(i)
|
|
||||||
scaled = BRepBuilderAPI_Transform(shape_tool.GetShape_s(label), trsf, True).Shape()
|
|
||||||
shape_tool.SetShape(label, scaled)
|
|
||||||
```
|
|
||||||
**Für künftige Projekte**: Immer OCP-Methoden-Verfügbarkeit mit `hasattr()` oder `dir()` testen bevor man sie aufruft.
|
|
||||||
|
|
||||||
### 2026-03-07 | OCP | `XCAFDoc_ShapeTool.GetComponents` → `GetComponents_s` (static method suffix)
|
|
||||||
**Problem**: `shape_tool.GetComponents(label, seq)` → `AttributeError: 'XCAFDoc_ShapeTool' has no attribute 'GetComponents'`
|
|
||||||
**Lösung**: In OCP sind alle XCAF static-Methoden mit `_s`-Suffix: `XCAFDoc_ShapeTool.GetComponents_s(label, seq)`. Gilt für alle `XCAFDoc_*`-Klassen.
|
|
||||||
|
|
||||||
### 2026-03-07 | Pipeline | OCC-native STEP→GLB ersetzt STL-Intermediary komplett
|
|
||||||
**Problem**: Pipeline nutzte STL als Zwischenformat (STEP→STL via cadquery, STL→Blender). STL verliert Materialnamen, Farben, OCC-Topologie. Zwei parallele Konvertierungen (cadquery STL + Blender Import) = doppelter Aufwand. STL-Cache auf Disk = Dateiflut.
|
|
||||||
**Lösung**: `export_step_to_gltf.py` nutzt OCC `RWGltf_CafWriter` direkt: STEP→GLB in einem Schritt. Koordinatensystem-Konvertierung (Z-up→Y-up) + mm→m Skalierung übernimmt `conv.SetInputLengthUnit(1e-3)`. Blender importiert GLB nativ via `bpy.ops.import_scene.gltf()` — kein `_scale_mm_to_m` mehr nötig.
|
|
||||||
**OCC-API-Gotcha**: Projekt nutzt `OCP` (cadquery's pythonocc-Bindings), NICHT `OCC.Core`. Statische Methoden haben `_s`-Suffix: `XCAFApp_Application.GetApplication_s()`, `XCAFDoc_DocumentTool.ShapeTool_s()`.
|
|
||||||
**Für künftige Projekte**: `RWGltf_CafWriter` + `STEPCAFControl_Reader` sind die kanonische STEP→GLB Pipeline. `BRepMesh_IncrementalMesh` tesselliert vor dem Export. `XCAFDoc_ColorTool.SetColor(label, color, XCAFDoc_ColorSurf)` überträgt Farben in GLB-Materialien.
|
|
||||||
|
|
||||||
### 2026-03-07 | Celery | generate_gltf_geometry_task als Subprocess — kein bpy-Import-Konflikt
|
|
||||||
**Problem**: OCP und bpy können nicht im selben Python-Prozess koexistieren — OCP lädt native C++-Bibliotheken die mit Blenders internen Versionen kollidieren. Der render-worker-Container hat BEIDE (cadquery + Blender).
|
|
||||||
**Lösung**: `export_step_to_gltf.py` via `sys.executable` subprocess aus Celery-Task heraus starten. So läuft OCC in isoliertem Prozess, kein Import-Pollution. Pattern: `subprocess.run([sys.executable, script, "--arg", val], timeout=120)`.
|
|
||||||
|
|
||||||
### 2026-03-07 | Bbox | GLB statt STL für Bounding-Box Extraktion
|
|
||||||
**Problem**: `_bbox_from_stl()` nutzte numpy binary parsing des STL-Headers (schnell, aber STL existiert nicht mehr nach Pipeline-Umbau).
|
|
||||||
**Lösung**: `_bbox_from_glb()` mit trimesh: `scene = trimesh.load(glb_path, force="scene"); bounds = scene.bounds`. GLB ist in Metern → `* 1000` für mm. Fallback auf `_bbox_from_step_cadquery()` bleibt erhalten.
|
|
||||||
|
|
||||||
### 2026-03-07 | Workflow | Turntable-Workflow brauchte step_path zur Laufzeit
|
|
||||||
**Problem**: `WorkflowDefinition.config` ist statisch (JSON) — enthält keine produktspezifischen Pfade. `_build_turntable()` erwartet `step_path` + `output_dir` in params → `ValueError` bei Workflow-Dispatch.
|
|
||||||
**Lösung**: `dispatch_render_with_workflow()` löst `step_path` + `output_dir` aus dem `OrderLine → Product → CadFile` Graph auf und injiziert sie in params vor `dispatch_workflow()`.
|
|
||||||
**Für künftige Projekte**: Workflow-Configs müssen zwischen statischen Parametern (engine, samples) und laufzeit-abhängigen (Dateipfade, IDs) unterscheiden. Letztere immer im Dispatch-Service auflösen.
|
|
||||||
|
|
||||||
### 2026-03-06 | Docker | `COPY --from=docker-cli cli-plugins` schlägt fehl wenn Pfad nicht existiert
|
|
||||||
**Problem**: `docker:cli` Image hat `/usr/local/bin/docker` aber KEIN `/usr/local/lib/docker/cli-plugins` Verzeichnis — `COPY --from` bricht ab.
|
|
||||||
**Lösung**: Nur `/usr/local/bin/docker` kopieren. Compose-Plugin wird über `docker compose` (space, nicht `-`) aufgerufen — das Binary enthält compose bereits bei neueren docker:cli Images.
|
|
||||||
|
|
||||||
### 2026-03-06 | OCC | Dihedralwinkel für sharp-edge Extraktion aus STEP
|
|
||||||
**Problem**: STEP-Dateien enthalten B-Rep Topologie, STL verliert Kantendaten. Blender braucht Winkelinformation für mark_sharp / UV-Seams.
|
|
||||||
**Lösung**: `topexp.MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map)` liefert alle Face-Paare pro Edge. `BRepAdaptor_Surface.DN()` berechnet Flächennormalen. Medianwinkel der Hartkanten → `suggested_smooth_angle`. Midpunkte in `sharp_edge_midpoints` für KD-Tree-Matching in Blender.
|
|
||||||
**Wichtig**: Nur im render-worker Container verfügbar (OCC + cadquery). Backend-Container gibt gracefully `{}` zurück.
|
|
||||||
|
|
||||||
### 2026-03-06 | Blender | UV-Seams aus sharp edges ableiten
|
|
||||||
**Problem**: Ohne korrekte UV-Seams wird Texturmapping auf Lagerteilen fehlerhaft.
|
|
||||||
**Lösung**: Nach `edges_select_sharp(sharpness=radians(angle))` → `mark_sharp()` → `mark_seam(clear=False)`. Optional: bmesh KD-Tree für OCC-Midpoints (Toleranz 0.5mm, vor scale(0.001)). Aufruf nach jedem _apply_smooth() in Mode A + B.
|
|
||||||
|
|
||||||
### 2026-03-06 | Workflow-Dispatch | dispatch_render_with_workflow als Drop-in für dispatch_order_line_render
|
|
||||||
**Problem**: Legacy-Code rief `dispatch_order_line_render.delay()` direkt auf. Neue Workflow-Canvas-Engine wurde nie aktiviert.
|
|
||||||
**Lösung**: `dispatch_render_with_workflow(order_line_id)` ist synchron (Celery-safe), lädt OutputType.workflow_definition_id, nutzt Canvas wenn gesetzt, fällt sonst auf Legacy zurück. In `dispatch_renders()` als Drop-in-Replacement mit try/except-Fallback.
|
|
||||||
|
|
||||||
### 2026-03-06 | Celery | `@shared_task` verbindet sich mit localhost statt Redis-Container
|
|
||||||
**Problem**: Neuer Celery-Task in `app/domains/materials/tasks.py` mit `@shared_task` (aus `celery`) statt `@celery_app.task` — beim Aufruf via FastAPI-Endpoint kam `kombu.exceptions.OperationalError: [Errno 111] Connection refused` weil `@shared_task` keinen expliziten App-Kontext hat und daher den Default-Broker `localhost:6379` nutzt, nicht `redis://redis:6379/0`.
|
|
||||||
**Lösung**: Immer `from app.tasks.celery_app import celery_app` importieren und `@celery_app.task(...)` nutzen. `@shared_task` nur verwenden wenn der Modul garantiert nach `celery_app.py` geladen wird (was in Domain-Modulen nicht der Fall ist).
|
|
||||||
|
|
||||||
### 2026-03-06 | SQLAlchemy | Relationship-Auflösung schlägt fehl wenn Models nicht alle importiert sind
|
|
||||||
**Problem**: Celery-Task importierte nur `AssetLibrary`, aber `Material.creator` hat eine String-Relationship zu `"User"`. SQLAlchemy kann den String-Verweis nur auflösen wenn `User` bereits im Mapper registriert ist → `InvalidRequestError: 'User' failed to locate a name`.
|
|
||||||
**Lösung**: `import app.models # noqa: F401` vor dem ersten DB-Zugriff in Celery-Tasks einfügen. Das `__init__.py` importiert alle 14 Modelle und registriert sie alle im SQLAlchemy-Mapper.
|
|
||||||
|
|
||||||
### 2026-03-06 | MinIO / Storage | `storage.upload()` erwartet `Path`, nicht `str`
|
|
||||||
**Problem**: `store_stl_cache(step_hash, quality, stl_path: str)` übergab einen `str` an `storage.upload(local_path, key)`. Die Implementierung nutzt intern `local_path.name` — `str` hat kein `.name`-Attribut → `AttributeError: 'str' object has no attribute 'name'`. STL-Dateien wurden lokal gespeichert, aber nie in MinIO gecacht.
|
|
||||||
**Lösung**: `storage.upload(Path(stl_path), key)` — immer `Path`-Objekt übergeben. Generell: alle `storage.upload()`-Aufrufe mit explizitem `Path()`-Cast absichern.
|
|
||||||
|
|
||||||
### 2026-03-06 | Blender / Scripts | `catalog_assets.py` Pfad in Docker falsch
|
|
||||||
**Problem**: Script-Pfad via `Path(__file__).parent...` aufgelöst — in Docker zeigt `__file__` auf den Python-Pfad im `backend`-Container, nicht im `render-worker`. Der render-worker kopiert Scripts nach `/render-scripts/` (via `COPY render-worker/scripts/ /render-scripts/`).
|
|
||||||
**Lösung**: `RENDER_SCRIPTS_DIR` Env-Var nutzen: `Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "catalog_assets.py"` — identisch zur Konvention in `domains/rendering/tasks.py`.
|
|
||||||
|
|
||||||
### 2026-01-15 | Architektur | Backend-Port-Konflikt
|
### 2026-01-15 | Architektur | Backend-Port-Konflikt
|
||||||
**Problem:** FastAPI standardmäßig auf Port 8000 — war auf dem Entwicklungsrechner belegt
|
Port 8000 war belegt → Port 8888 in `docker-compose.yml` + Vite-Proxy. Früh festlegen und in CLAUDE.md dokumentieren.
|
||||||
**Lösung:** Port 8888 in `docker-compose.yml` und Vite-Proxy konfiguriert
|
|
||||||
**Für künftige Projekte:** Port früh festlegen und in CLAUDE.md dokumentieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-01-20 | Datenbank | SQLAlchemy trackt key-value-Store-Mutations nicht
|
### 2026-01-20 | Datenbank | SQLAlchemy trackt key-value-Store-Mutations nicht
|
||||||
**Problem:** Admin-Einstellungen (`system_settings`) wurden via ORM gespeichert, Änderungen wurden nicht persistiert
|
Admin-Settings via ORM gespeichert → Änderungen nicht persistiert. SQLAlchemy erkennt keine Mutation an geladenen Value-Feldern.
|
||||||
**Ursache:** SQLAlchemy erkennt keine Mutation an einem bereits geladenen Objekt wenn nur ein Value-Feld geändert wird
|
**Lösung:** Direktes `session.execute(update(...))` statt ORM-Mutation. Key-Value-Stores immer mit direktem SQL verwalten.
|
||||||
**Lösung:** Direktes SQL `UPDATE` via `op.execute()` statt ORM-Mutation in `admin.py`
|
|
||||||
**Für künftige Projekte:** Key-Value-Stores immer mit direktem SQL oder `session.execute(update(...))` verwalten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-01-25 | Render-Pipeline | Blender ignoriert STEP-Einheiten (mm vs. m)
|
### 2026-01-25 | Render-Pipeline | Blender ignoriert STEP-Einheiten (mm vs. m)
|
||||||
**Problem:** STEP-Dateien sind in Millimetern, Blender arbeitet intern in Metern → 50mm-Lager erscheint 50 Meter breit, Kamera framt falsch
|
STEP in mm, Blender in m → 50mm-Lager erscheint 50m breit. `_scale_mm_to_m(parts)` in allen 3 Render-Scripts: `part.scale = (0.001, 0.001, 0.001)` + Transform anwenden, direkt nach STL-Import vor Kamera-Kalkulation.
|
||||||
**Lösung:** `_scale_mm_to_m(parts)` Helper in allen 3 Render-Scripts: `part.scale = (0.001, 0.001, 0.001)`, Transform anwenden
|
|
||||||
**Betroffene Dateien:** `blender_render.py`, `still_render.py`, `turntable_render.py`
|
|
||||||
**Für künftige Projekte:** Einheiten-Konvertierung direkt nach STL-Import, vor jeder Kamera-Kalkulation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-01-28 | Render-Pipeline | Blender 5.0 hat `scene.node_tree` entfernt
|
### 2026-01-28 | Render-Pipeline | Blender 5.0 hat `scene.node_tree` entfernt
|
||||||
**Problem:** `_setup_bg_compositor()` rief `scene.node_tree` auf (in Blender 5.0 entfernt) → Python-Exception → Blender exitete mit Code 0 → Flamenco markierte Task fälschlicherweise als "completed"
|
`_setup_bg_compositor()` → Python-Exception → Blender exit code 0 → Task fälschlicherweise "completed".
|
||||||
**Lösung:** `_setup_bg_compositor()` aus Setup + Render-Script entfernt; bg_color-Kompositing in FFmpeg verschoben (`-f lavfi -i color=...` + overlay-Filter)
|
**Lösung:** Compositing entfernt; bg_color via FFmpeg (`-f lavfi -i color=...` + overlay). Pflicht: `try: main() except SystemExit: raise except Exception: traceback; sys.exit(1)` in allen Blender-Scripts.
|
||||||
**Wichtig:** Immer `try: main() except SystemExit: raise except Exception: traceback; sys.exit(1)` in Blender-Scripts — sonst verschluckt Blender Python-Exceptions
|
|
||||||
**Für künftige Projekte:** Nach Blender-Major-Updates alle API-Calls prüfen; Exception-Guard ist Pflicht
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-05 | Material-System | Material-Alias-Lookup-Reihenfolge falsch
|
### 2026-02-05 | Material-System | Material-Alias-Lookup-Reihenfolge falsch
|
||||||
**Problem:** `Steel--Stahl` war sowohl ein kanonischer `Material.name` als auch ein Alias für `SCHAEFFLER_010101_Steel-Bare`. Der Lookup prüfte zuerst den exakten Namen und fand `Steel--Stahl` — Blender konnte diesen Namen aber nicht in der Library finden
|
`Steel--Stahl` war sowohl `Material.name` als auch Alias für `SCHAEFFLER_010101_Steel-Bare`. Lookup fand zuerst den Namen → Blender konnte ihn nicht in der Library finden.
|
||||||
**Lösung:** Lookup-Reihenfolge in `material_service.py` umgekehrt: **Aliases zuerst**, dann exakter Name, dann Pass-through
|
**Lösung:** `material_service.py`: **Aliases zuerst**, dann exakter Name, dann Pass-through.
|
||||||
**Für künftige Projekte:** Alias-System immer so designen dass Aliases Vorrang haben; nie zwei Lookup-Pfade mit überlappenden Treffern
|
|
||||||
|
|
||||||
---
|
### 2026-02-10 | Render-Pipeline | Blender-Template überschreibt HDRI/World
|
||||||
|
Im Template-Modus (Mode B) lief Auto-Licht/World-Setup bedingungslos → überschrieb HDRI aus .blend-Template.
|
||||||
### 2026-02-10 | Render-Pipeline | Blender-Template zerstört HDRI/World
|
**Lösung:** In Template-Mode Lights, World und Color-Management-Override vollständig überspringen; nur Kamera ggf. neu berechnen.
|
||||||
**Problem:** Im Template-Modus (Mode B) wurden trotzdem Auto-Lights und eine neue World erstellt → überschrieb den HDRI aus dem .blend-Template → falsche Beleuchtung
|
|
||||||
**Ursache:** Auto-Licht- und World-Setup-Code lief bedingungslos, nicht nur im Mode A
|
|
||||||
**Lösung:** In Template-Mode werden Lights, World und Color-Management-Override vollständig übersprungen; nur die Kamera wird ggf. neu berechnet
|
|
||||||
**Betroffene Dateien:** `still_render.py`, `turntable_render.py`, `schaeffler-still.js`, `schaeffler-turntable.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-15 | Celery | Blender-Queue-Flooding durch falsche Concurrency
|
### 2026-02-15 | Celery | Blender-Queue-Flooding durch falsche Concurrency
|
||||||
**Problem:** Alle Celery-Tasks (schnelle Metadata-Extraktion + langsamer Blender-Render) liefen auf `step_processing` mit concurrency=8 → 8 Workers schickten gleichzeitig Requests an blender-renderer (der nur 1 gleichzeitig verarbeiten kann) → 7 davon liefen in 300s-Timeout → blockierte die gesamte Queue
|
Alle Tasks auf `step_processing` (concurrency=8) → 8 Workers gleichzeitig an blender-renderer (max 1) → 7× Timeout.
|
||||||
**Lösung:** Pipeline aufgeteilt:
|
**Lösung:** `process_step_file` (step_processing, concurrency=8) nur schnelle Metadata; `render_step_thumbnail` (thumbnail_rendering, concurrency=1) für Blender. HTTP-Services mit max 1 Request immer auf eigener Queue mit concurrency=1.
|
||||||
- `process_step_file` (step_processing, concurrency=8): nur schnelle Metadata-Extraktion (<2s), queut dann →
|
|
||||||
- `render_step_thumbnail` (thumbnail_rendering, concurrency=1): Blender-Call, niemals timeout
|
|
||||||
**Neuer Service:** `worker-thumbnail` in `docker-compose.yml` mit `--concurrency=1`
|
|
||||||
**Für künftige Projekte:** HTTP-Services die nur 1 Request gleichzeitig verarbeiten können IMMER auf einer separaten Queue mit concurrency=1 laufen lassen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-18 | Frontend | Tailwind CSS-Variablen inkompatibel mit opacity-Syntax
|
### 2026-02-18 | Frontend | Tailwind CSS-Variablen inkompatibel mit opacity-Syntax
|
||||||
**Problem:** `bg-surface/50` oder `bg-surface` (wenn `--color-bg-surface` ein Hex-Wert ist) generiert `rgb(var(--color-bg-surface) / 0.5)` — invalides CSS, weil `rgb()` keine Hex-Werte als Channel-Input akzeptiert → Hintergrund transparent
|
`bg-surface/50` erzeugt `rgb(var(--color-bg-surface) / 0.5)` — invalides CSS wenn Variable ein Hex-Wert ist.
|
||||||
**Ursache:** Tailwind erwartet CSS-Variablen mit RGB-Channel-Format (`255 255 255`), nicht Hex (`#ffffff`)
|
**Lösung:** `style={{ backgroundColor: 'var(--color-bg-surface)' }}`. Alternativ: CSS-Vars im RGB-Channel-Format definieren (`255 255 255`).
|
||||||
**Lösung:** Inline-Style verwenden: `style={{ backgroundColor: 'var(--color-bg-surface)' }}`
|
|
||||||
**Für künftige Projekte:** Entweder CSS-Variablen im RGB-Channel-Format definieren, oder konsequent inline styles für variable Farben
|
|
||||||
|
|
||||||
---
|
### 2026-02-20 | STL-Cache | Three.js-Renderer nutzte tempfile → kein Download
|
||||||
|
Three.js schrieb STL in tempfile und löschte es → Download-Endpoint fand nichts.
|
||||||
### 2026-02-20 | STL-Cache | Three.js-Renderer nutzte tempfile → kein Download möglich
|
**Lösung:** Persistent cache: `step_path.parent / f"{step_path.stem}_low.stl"`, Cache-Hit-Check vor Konvertierung, kein `unlink()`. Konvention `{stem}_{quality}.stl` neben STEP-Datei von Anfang an in allen Renderern.
|
||||||
**Problem:** Three.js-Renderer konvertierte STEP→STL in ein tempfile und löschte es anschließend → STL-Download-Endpoint fand keine Datei
|
|
||||||
**Ursache:** Three.js war ursprünglich nur für Thumbnails gebaut, STL-Cache-Konvention (`{stem}_low.stl` neben STEP-Datei) wurde nicht implementiert
|
|
||||||
**Lösung:** Persistent cache path: `step_path.parent / f"{step_path.stem}_low.stl"`, cache-hit-check vor Konvertierung, kein `unlink()` mehr
|
|
||||||
**Für künftige Projekte:** STL-Cache-Konvention (`{step_stem}_{quality}.stl` neben STEP-Datei) von Anfang an in allen Renderer-Services einhalten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-20 | STL-Cache | blender-renderer fehlte /convert-stl Endpoint
|
### 2026-02-20 | STL-Cache | blender-renderer fehlte /convert-stl Endpoint
|
||||||
**Problem:** Für Produkte die mit Blender gerendert wurden war kein STL-Cache vorhanden wenn nicht explizit gerendert wurde (blender-renderer renderte + konvertierte in einem Schritt, aber STL wurde nicht persistiert)
|
Blender renderte + konvertierte in einem Schritt, persistierte STL nicht.
|
||||||
**Lösung:** Neuer `/convert-stl` Endpoint in `blender-renderer/app.py`: konvertiert STEP→STL ohne Render, persistiert Cache. Neuer Celery-Task `generate_stl_cache` auf `thumbnail_rendering`-Queue. Admin-Funktion "Generate Missing STLs" zum Batch-Nachfüllen
|
**Lösung:** Neuer `/convert-stl` Endpoint (STEP→STL ohne Render), Celery-Task `generate_stl_cache` auf `thumbnail_rendering`, Admin-Batch-Funktion "Generate Missing STLs".
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-22 | Material-System | Fehlender Alias blockiert Material-Replacement
|
### 2026-02-22 | Material-System | Fehlender Alias blockiert Material-Replacement
|
||||||
**Problem:** Produkt F-803422.01.TR2 (SA-2026-00080) renderte ohne Materialersetzung. Material "Stahl v2" war korrekt in der UI gespeichert, aber weder in `materials` noch in `material_aliases` vorhanden
|
`"Stahl v2"` in DB nicht in materials noch in material_aliases → keine Ersetzung, Silent-Fail.
|
||||||
**Ursache:** Alias-Seeding aus Excel deckte nicht alle Varianten der deutschen Materialbezeichnungen ab
|
**Lösung:** Alias direkt in DB eintragen. Bei Render ohne Materialersetzung immer zuerst `resolve_material_map()` debuggen, Alias-Tabelle prüfen.
|
||||||
**Lösung:** Alias direkt in DB eingetragen: `"Stahl v2"` → `SCHAEFFLER_010101_Steel-Bare`
|
|
||||||
**Für künftige Projekte:** Bei Render ohne Materialersetzung immer zuerst `resolve_material_map()` debuggen und Alias-Tabelle prüfen; Alias-Seeding regelmäßig mit neuen Excel-Varianten erweitern
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-25 | Frontend | canDispatch-Bedingung zu restriktiv
|
### 2026-02-25 | Frontend | canDispatch-Bedingung zu restriktiv
|
||||||
**Problem:** "Dispatch Renders"-Button war nicht sichtbar obwohl der Auftrag offene Render-Zeilen hatte
|
`canDispatch` enthielt `&& hasRetryable` → Button fehlte wenn alle Zeilen initial "pending".
|
||||||
**Ursache:** `canDispatch` enthielt `&& hasRetryable` — Button erschien nur wenn pending/failed/cancelled-Zeilen vorhanden waren, nicht wenn alle Zeilen "pending" im Erstauftrag
|
**Lösung:** `hasRetryable` entfernt; Validierung im Backend. Aktions-Buttons nicht zu stark von abgeleiteten Zuständen abhängig machen.
|
||||||
**Lösung:** `hasRetryable`-Bedingung entfernt; Button ist immer sichtbar wenn Auftrag im richtigen Status und User privilegiert ist
|
|
||||||
**Für künftige Projekte:** Aktions-Buttons nicht zu stark von abgeleiteten Zuständen abhängig machen; lieber im Backend validieren
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-02-28 | Frontend | MaterialInput-Dropdown ohne Hintergrund
|
### 2026-02-28 | Frontend | MaterialInput-Dropdown ohne Hintergrund
|
||||||
**Problem:** Dropdown der Material-Suchfeld-Komponente erschien transparent — Text über dem Hintergrund kaum lesbar
|
`bg-surface` mit CSS-Hex-Variable → transparenter Dropdown (siehe 2026-02-18).
|
||||||
**Ursache:** `bg-surface` Tailwind-Klasse + CSS-Variable mit Hex-Wert (siehe Learning 2026-02-18)
|
**Lösung:** `style={{ backgroundColor: 'var(--color-bg-surface)' }}` für Dropdown-Container, Group-Header, Sticky-Button. Datei: `frontend/src/components/shared/MaterialInput.tsx`.
|
||||||
**Lösung:** `style={{ backgroundColor: 'var(--color-bg-surface)' }}` für Dropdown-Container, Group-Header und Sticky-Button
|
|
||||||
**Datei:** `frontend/src/components/shared/MaterialInput.tsx`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Refactor | .gitignore `core` trifft Verzeichnisse
|
### 2026-03-06 | Refactor | .gitignore `core` trifft Verzeichnisse
|
||||||
**Problem:** `.gitignore` enthielt `core` als Regel (für core dump files) — Git ignorierte damit auch `backend/app/core/` Verzeichnis
|
`core` in `.gitignore` ignorierte `backend/app/core/` Verzeichnis.
|
||||||
**Lösung:** Regel zu `/core` umbenannt (Root-relative Regel trifft nur `/core` Datei, nicht verschachtelte `core/`-Verzeichnisse)
|
**Lösung:** `/core` (root-relativ) — trifft nur Datei im Root, nicht verschachtelte Verzeichnisse.
|
||||||
**Für künftige Projekte:** Immer Root-relative Pfade (`/core`) für Dateien im Root-Verzeichnis nutzen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Architektur | Blender-HTTP-Service vs. direkter Subprocess
|
### 2026-03-06 | Architektur | Blender-HTTP-Service vs. direkter Subprocess
|
||||||
**Problem:** `blender-renderer` als Flask/FastAPI HTTP-Microservice war ein Single-Point-of-Failure (max. 1 concurrent Request), kein Scaling möglich, HTTP-Overhead bei jedem Render
|
Flask-HTTP-Microservice = Single-Point-of-Failure, kein Scaling, HTTP-Overhead.
|
||||||
**Lösung:** Render-Worker als Celery-Container (`render-worker/`) — Blender direkt via `subprocess.run` ohne HTTP. `is_blender_available()` prüft `BLENDER_BIN` env var für Kontext-Detection
|
**Lösung:** Celery render-worker Container — Blender direkt via `subprocess.run`. `is_blender_available()` prüft `BLENDER_BIN` env var; Backend-Container fallen auf Pillow zurück. Subprocess > HTTP für blocking compute tasks.
|
||||||
**Wichtig:** `step_processor.py` erkennt über `BLENDER_BIN`-Env ob Blender im aktuellen Container verfügbar ist — Backend-Container fallen auf Pillow zurück
|
|
||||||
**Für künftige Projekte:** Subprocess-basierter Renderer > HTTP-Microservice für blocking compute tasks
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Refactor | Bash CWD-Problem durch Hook-Pfad-Auflösung
|
### 2026-03-06 | Refactor | Bash CWD-Problem durch Hook-Pfad-Auflösung
|
||||||
**Problem:** Nach `cd frontend && npm test` in einem Bash-Tool-Call blieb CWD dauerhaft in `frontend/`. Der Pre-Tool-Use-Hook `python3 .claude/hooks/pre_tool_use.py` wurde dann relativ zu `frontend/` aufgelöst → Datei nicht gefunden → alle Tool-Calls blockiert
|
Nach `cd frontend` im Bash-Tool blieb CWD in `frontend/` → Hook-Pfad nicht gefunden → alle Tool-Calls blockiert.
|
||||||
**Lösung:** Symlink `frontend/.claude → .claude` erstellt: `ln -sf $(pwd)/.claude frontend/.claude`
|
**Lösung:** Symlink `frontend/.claude → .claude`. Hooks mit absoluten Pfaden konfigurieren; `cd` nur mit `&&` am Ende der Command-Chain.
|
||||||
**Für künftige Projekte:** Hooks nie mit relativen Pfaden konfigurieren; absoluten Pfad im Hook-Command verwenden. Außerdem: `cd` immer in separate Bash-Calls oder mit `&&` am Ende der eigentlichen Command-Chain
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Multi-Tenancy | PostgreSQL RLS mit current_setting und Null-Safety
|
### 2026-03-06 | Multi-Tenancy | PostgreSQL RLS mit current_setting und Null-Safety
|
||||||
**Problem:** `current_setting('app.current_tenant_id')` wirft Exception wenn Variable nicht gesetzt → alle Queries schlagen fehl wenn kein Tenant-Context gesetzt ist
|
`current_setting('app.current_tenant_id')` wirft Exception wenn nicht gesetzt.
|
||||||
**Lösung:** `current_setting('app.current_tenant_id', true)` — zweites Argument `true` macht die Funktion Null-safe: gibt NULL statt Exception zurück wenn Setting nicht gesetzt
|
**Lösung:** `current_setting('app.current_tenant_id', true)` — zweites Argument macht Funktion Null-safe. Admin-Bypass: separate Policy mit `SET LOCAL app.current_tenant_id = 'bypass'`.
|
||||||
**Admin-Bypass-Pattern:** Separates `CREATE POLICY admin_bypass ... USING (current_setting(...) = 'bypass')` — setzt `app.current_tenant_id = 'bypass'` für Admin-Cross-Tenant-Queries
|
|
||||||
**Für künftige Projekte:** IMMER das zweite `true`-Argument verwenden; Policies immer testen mit (a) gesetztem Tenant, (b) nicht gesetztem Setting, (c) Admin-Bypass
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Refactor | Domain-Driven Migration: Compat-Shims statt Big-Bang
|
### 2026-03-06 | Refactor | Domain-Driven Migration: Compat-Shims statt Big-Bang
|
||||||
**Problem:** Vollständige Migration aller Models/Services/Router in neue Domain-Struktur in einem Schritt → alle bestehenden Imports brechen
|
Vollständige Migration in einem Schritt bricht alle Imports.
|
||||||
**Lösung:** Compat-Shims-Ansatz: alte Dateien (`app/models/user.py` etc.) werden zu Re-Export-Wrappern die aus den neuen Domain-Locations importieren. So funktionieren alle bestehenden Imports weiter während die kanonische Location die neue Domain ist
|
**Lösung:** Alte Dateien werden Re-Export-Shims: `from app.domains.auth.models import User; __all__ = ["User"]`. Erst nach vollständiger Import-Migration Shims entfernen.
|
||||||
**Pattern:**
|
|
||||||
```python
|
|
||||||
# app/models/user.py (Compat-Shim)
|
|
||||||
from app.domains.auth.models import User
|
|
||||||
__all__ = ["User"]
|
|
||||||
```
|
|
||||||
**Für künftige Projekte:** Immer Compat-Shims anlegen vor dem Verschieben; erst nach vollständiger Migration aller Imports die Shims entfernen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Workflow-System | Celery Canvas vs. Custom Workflow-Engine
|
### 2026-03-06 | Workflow-System | Celery Canvas vs. Custom Workflow-Engine
|
||||||
**Problem:** Custom Workflow-Engine (Graph-Traversal, Dependency-Resolution, Retry-Logic) war zu komplex (~2-3 Wochen Eigenentwicklung)
|
Custom Graph-Traversal-Engine = 2-3 Wochen Eigenentwicklung.
|
||||||
**Lösung:** Celery Canvas als Execution-Engine (`chain`, `group`, `chord`). `dispatch_workflow(type, order_line_id, params)` baut den Canvas dynamisch aus Config-Typ. Backward-Compat: wenn kein `workflow_definition_id` → alter direkter Task-Call
|
**Lösung:** Celery Canvas (`chain`, `group`, `chord`) als Execution-Engine. `dispatch_workflow(type, order_line_id, params)` baut Canvas dynamisch aus Config. Kein Backward-Compat nötig: Celery Canvas reicht für parallele/sequentielle Workflows.
|
||||||
**Seeded Workflows:** 3 Standard-Definitionen beim Migration-Upgrade direkt in DB geSEEDed (Still, Turntable, Multi-Angle)
|
|
||||||
**Für künftige Projekte:** Celery Canvas ist ausreichend für parallele/sequentielle Workflow-Execution; keine eigene Workflow-Engine bauen
|
|
||||||
|
|
||||||
---
|
### 2026-03-06 | Circular Import | template_service ↔ domains/rendering/service
|
||||||
|
Beide Module waren Shims die aufeinander zeigten → `resolve_template()` nie aufrufbar → alle Renders crashten mit ImportError.
|
||||||
### 2026-03-06 | Circular Import | template_service ↔ domains/rendering/service — Render nie ausgeführt
|
**Lösung:** `template_service.py` aus git-History wiederhergestellt (echte Implementierung). `domains/rendering/service.py` importiert nur davon. Nach Refactoring: `grep -rn "def resolve_template"` muss ≥1 Treffer liefern.
|
||||||
**Problem:** `app.services.template_service` war ein Shim der `app.domains.rendering.service` importiert. `app.domains.rendering.service` importierte wiederum `app.services.template_service` → zirkulärer Import → `resolve_template` konnte nie geladen werden → jeder Render schlug fehl mit "cannot import name 'resolve_template' from partially initialized module".
|
|
||||||
**Ursache:** B1-Refactor hat beide Module zu Shims gemacht die aufeinander zeigen. Die eigentliche Implementierung wurde nicht in die neue Domäne übertragen.
|
|
||||||
**Lösung:** `template_service.py` mit der Originalimplementierung aus dem git-Log wiederhergestellt (sync SQLAlchemy, Celery-sicher, 4-stufige Cascade). `domains/rendering/service.py` importiert jetzt korrekt aus `template_service` ohne Rückimport.
|
|
||||||
**Für künftige Projekte:** Nach Refactoring immer prüfen ob Shims auf die echte Implementierung zeigen oder wieder auf andere Shims. `grep -rn "def resolve_template"` vor dem Commit muss mindestens 1 Treffer liefern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Multi-Tenancy | audit_log.tenant_id NOT NULL blockiert alle Notifications
|
### 2026-03-06 | Multi-Tenancy | audit_log.tenant_id NOT NULL blockiert alle Notifications
|
||||||
**Problem:** Migration 036 machte `audit_log.tenant_id NOT NULL`, aber `emit_notification` setzt kein `tenant_id`. Die Notification-Insert schlug fehl → rollback → nachfolgende Session-Zugriffe schlugen fehl → Order-Submit gab 500 zurück.
|
Migration 036 machte `audit_log.tenant_id NOT NULL`, aber `emit_notification` setzt kein `tenant_id` → INSERT schlägt fehl → 500 bei Order-Submit.
|
||||||
**Lösung:** `audit_log.tenant_id` via `ALTER TABLE audit_log ALTER COLUMN tenant_id DROP NOT NULL` nullable gemacht. Broadcast-Notifications (system-weit, kein konkreter Tenant) DÜRFEN NULL tenant_id haben.
|
**Lösung:** `ALTER TABLE audit_log ALTER COLUMN tenant_id DROP NOT NULL`. Broadcast-Notifications (system-weit) dürfen NULL tenant_id haben.
|
||||||
**Für künftige Projekte:** Audit-Logs die als Broadcast an alle Tenants gehen benötigen nullable tenant_id. Nie NOT NULL auf Tabellen setzen die auch System-Events speichern.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Frontend | GET /api/tenants gibt 307 Redirect zurück
|
### 2026-03-06 | Frontend | GET /api/tenants gibt 307 Redirect zurück
|
||||||
**Problem:** FastAPI router registriert `/tenants/` (mit trailing slash). `GET /tenants` → 307 Redirect zu `/tenants/`. Axios folgt dem Redirect aber verliert den Authorization-Header → 401 → leere Tenant-Liste im Frontend.
|
FastAPI registriert `/tenants/` (trailing slash) → `GET /tenants` → 307. Axios folgt, verliert Authorization-Header → 401.
|
||||||
**Lösung:** `getTenants()` in `api/tenants.ts` auf `/tenants/` (mit trailing slash) geändert.
|
**Lösung:** `getTenants()` auf `/tenants/` (mit trailing slash). Generell: FastAPI 307-Redirects verlieren Auth-Header in Axios. Immer trailing slash im Frontend verwenden.
|
||||||
**Für künftige Projekte:** FastAPI APIRouter mit `prefix="/tenants"` und `@router.get("")` erzeugt `/tenants` (kein Slash). Mit `@router.get("/")` erzeugt `/tenants/`. Axios folgt 307 nicht mit Auth-Header. Immer trailing slash im Frontend verwenden wenn Router mit Slash registriert.
|
|
||||||
|
|
||||||
---
|
### 2026-03-06 | Celery Canvas | workflow_builder: order_line_id als step_path übergeben
|
||||||
|
`render_still_task.si(order_line_id, **params)` — Task erwartet `step_path: str`. Blender versuchte UUID als Pfad → crash.
|
||||||
### 2026-03-06 | Celery Canvas | workflow_builder.py: order_line_id als step_path übergeben crasht Blender
|
**Lösung:** Separate `render_order_line_still_task` die intern OrderLine→Product→CadFile auflöst. Workflow-Builder-Tasks dürfen nie Domain-IDs als file-path-Argumente verwenden.
|
||||||
**Problem:** `_build_still` übergab `order_line_id` als ersten Positional-Arg an `render_still_task.si(order_line_id, **params)` — aber `render_still_task` erwartet `step_path: str` als ersten Arg. Blender versuchte die UUID als Pfad zu öffnen → crash.
|
|
||||||
**Lösung:** Neue `render_order_line_still_task` die intern die DB-Abfrage macht (OrderLine → Product → CadFile → stored_path). `workflow_builder._build_still` nutzt jetzt diese neue Task.
|
|
||||||
**Für künftige Projekte:** Workflow-Builder-Tasks dürfen nie Domain-IDs als file-path-basierte Task-Argumente verwenden. Immer separate order-line-aware Tasks erstellen die die Auflösung intern durchführen.
|
|
||||||
|
|
||||||
### 2026-03-06 | Docker | docker compose in Container braucht multi-stage CLI-Copy
|
### 2026-03-06 | Docker | docker compose in Container braucht multi-stage CLI-Copy
|
||||||
**Problem:** Backend-Container basiert auf `python:3.11-slim` — kein `docker` binary, kein `docker compose`. Worker-Scale-Endpoint kann `docker compose up --scale` nicht aufrufen.
|
`python:3.11-slim` hat kein `docker` binary.
|
||||||
**Lösung:** Multi-Stage Dockerfile: `COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker` + `COPY --from=docker-cli /usr/local/lib/docker/cli-plugins /usr/local/lib/docker/cli-plugins`. Außerdem: Docker-Socket mounten (`/var/run/docker.sock`) + Compose-File als Volume (`./:/compose:ro`) + `COMPOSE_PROJECT_DIR=/compose` env var.
|
**Lösung:** `COPY --from=docker:cli /usr/local/bin/docker /usr/local/bin/docker` + cli-plugins. Docker-Socket mounten (`/var/run/docker.sock`), Compose-File als Volume, `COMPOSE_PROJECT_DIR` env var.
|
||||||
**Für künftige Projekte:** Multi-Stage-Builds sind die sauberste Methode um Binaries aus anderen Images zu kopieren ohne die ganze Dependency-Chain zu installieren.
|
|
||||||
|
### 2026-03-06 | Docker | `COPY --from=docker-cli cli-plugins` schlägt fehl
|
||||||
|
`docker:cli` Image hat kein `/usr/local/lib/docker/cli-plugins` Verzeichnis.
|
||||||
|
**Lösung:** Nur `/usr/local/bin/docker` kopieren — neuere docker:cli Images enthalten `compose` bereits im Binary.
|
||||||
|
|
||||||
|
### 2026-03-06 | Docker | apt-Paketname libgdk-pixbuf2.0-0 vs libgdk-pixbuf-2.0-0
|
||||||
|
Auf Debian bookworm heißt das Paket `libgdk-pixbuf-2.0-0` (mit Bindestrichen). `apt-get install` mit falschem Namen → exit 100.
|
||||||
|
**Regel:** Immer `apt-cache search libgdk` im Container prüfen.
|
||||||
|
|
||||||
### 2026-03-06 | React Three Fiber | Wireframe-Toggle über Material-Clone
|
### 2026-03-06 | React Three Fiber | Wireframe-Toggle über Material-Clone
|
||||||
**Problem:** Drei.js-Materialien sind shared objects — direkte Mutation von `child.material.wireframe = true` auf einem geparstem GLTF-Scene würde alle Instanzen dieses Materials beeinflussen.
|
GLTF-Materialien sind shared objects — direkte Mutation von `child.material.wireframe` beeinflusst alle Instanzen.
|
||||||
**Lösung:** `child.material = child.material.clone()` vor der Wireframe-Mutation in `useEffect`. So bekommt jede Mesh-Instanz ihr eigenes Material-Objekt und der Toggle hat keinen unerwünschten Side-Effect.
|
**Lösung:** `child.material = child.material.clone()` vor Wireframe-Mutation. GLTF-Materialien bei Runtime-Modifikationen immer zuerst clonen.
|
||||||
**Für künftige Projekte:** GLTF-Materialien bei Runtime-Modifikationen immer zuerst clonen.
|
|
||||||
|
|
||||||
### 2026-03-06 | pytest | Backend ohne dev-Dependencies: pip install -e ".[dev]" nötig
|
### 2026-03-06 | pytest | Backend ohne dev-Dependencies
|
||||||
**Problem:** Backend-Dockerfile installiertete nur `pip install -e .` — keine dev-Dependencies → pytest/pytest-asyncio/httpx nicht verfügbar → `python -m pytest` schlägt mit "No module named pytest" fehl.
|
`pip install -e .` ohne `[dev]` → kein pytest → `ModuleNotFoundError`.
|
||||||
**Lösung:** Dockerfile geändert auf `pip install -e ".[dev]"`. Dev-Dependencies in `pyproject.toml [project.optional-dependencies] dev = [pytest>=8.0, ...]` waren bereits definiert, nur der Install-Befehl war unvollständig.
|
**Lösung:** `pip install -e ".[dev]"`. Dev-Extras immer im Dockerfile angeben wenn Tests im Container laufen sollen.
|
||||||
**Für künftige Projekte:** Immer prüfen ob `[dev]` extras installiert sind wenn Tests im Container laufen sollen.
|
|
||||||
|
### 2026-03-06 | Celery | `@shared_task` verbindet sich mit localhost statt Redis-Container
|
||||||
|
`@shared_task` aus `celery` ohne App-Kontext → Default-Broker `localhost:6379` → `kombu.exceptions.OperationalError`.
|
||||||
|
**Lösung:** Immer `from app.tasks.celery_app import celery_app` + `@celery_app.task(...)`. `@shared_task` nur wenn Modul garantiert nach `celery_app.py` geladen wird.
|
||||||
|
|
||||||
|
### 2026-03-06 | SQLAlchemy | Relationship-Auflösung schlägt fehl wenn Models nicht importiert
|
||||||
|
Celery-Task importierte nur `AssetLibrary` → `Material.creator` → String-Ref `"User"` nicht im Mapper → `InvalidRequestError`.
|
||||||
|
**Lösung:** `import app.models # noqa: F401` vor erstem DB-Zugriff in Celery-Tasks — `__init__.py` registriert alle 14 Modelle.
|
||||||
|
|
||||||
|
### 2026-03-06 | MinIO / Storage | `storage.upload()` erwartet `Path`, nicht `str`
|
||||||
|
`storage.upload(str_path, key)` → `AttributeError: 'str' has no attribute 'name'` → STLs lokal gespeichert aber nie in MinIO.
|
||||||
|
**Lösung:** `storage.upload(Path(stl_path), key)` — alle `upload()`-Aufrufe mit `Path()`-Cast absichern.
|
||||||
|
|
||||||
|
### 2026-03-06 | Blender / Scripts | `catalog_assets.py` Pfad in Docker falsch
|
||||||
|
`Path(__file__).parent` zeigt auf Backend-Container-Pfad, nicht auf render-worker `/render-scripts/`.
|
||||||
|
**Lösung:** `RENDER_SCRIPTS_DIR` Env-Var: `Path(os.environ.get("RENDER_SCRIPTS_DIR", "/render-scripts")) / "catalog_assets.py"`.
|
||||||
|
|
||||||
|
### 2026-03-06 | Alembic | Migration exit code 100 bei enum-Konflikt
|
||||||
|
`Enum(create_type=False)` unzuverlässig mit asyncpg.
|
||||||
|
**Lösung:** Raw SQL: `DO $$ BEGIN CREATE TYPE ...; EXCEPTION WHEN duplicate_object THEN NULL; END $$;`. Für Tabellen: `CREATE TABLE IF NOT EXISTS`.
|
||||||
|
|
||||||
|
### 2026-03-06 | Render-Pipeline | Circular Shim blockiert alle Order-Renders
|
||||||
|
`dispatch_order_line_render` → Shim A→B→A → Render startet nie.
|
||||||
|
**Lösung:** `dispatch_order_line_render` direkt auf `render_order_line_task.delay()`. Den echten Aufruf-Pfad API→Task vor Refactoring dokumentieren.
|
||||||
|
|
||||||
|
### 2026-03-06 | Render-Pipeline | render_order_line_task auf falschem Worker (kein Blender)
|
||||||
|
Task auf `step_processing` → `worker`-Container (kein Blender) → `is_blender_available()` = False → Pillow-Placeholder, kein Fehler.
|
||||||
|
**Lösung:** Queue auf `thumbnail_rendering` → `render-worker`-Container (Blender 5.0.1 + cadquery). Blender-Tasks IMMER auf `thumbnail_rendering`.
|
||||||
|
|
||||||
|
### 2026-03-06 | Docker | worker-thumbnail vs render-worker — beide auf thumbnail_rendering
|
||||||
|
Zwei Services unterschiedlicher Capabilities auf gleicher Queue → round-robin → 50% Silent-Fail.
|
||||||
|
**Lösung:** `worker-thumbnail` aus docker-compose entfernt. `render-worker` ist alleiniger Consumer. Nie zwei Services mit unterschiedlichen Fähigkeiten auf dieselbe Queue.
|
||||||
|
|
||||||
|
### 2026-03-06 | Multi-Tenancy | tenant_id NOT NULL verletzt bei Order-Erstellung
|
||||||
|
Migration 036 machte `tenant_id NOT NULL` auf orders/order_lines/order_items → alle Create-Endpoints übergaben das Feld nicht.
|
||||||
|
**Lösung:** `tenant_id=getattr(user, 'tenant_id', None)` in allen Model-Konstruktoren. Nach jeder RLS-Migration alle Create-Endpoints auf neue Pflichtfelder prüfen.
|
||||||
|
|
||||||
|
### 2026-03-06 | Workflow-Dispatch | dispatch_render_with_workflow als Drop-in
|
||||||
|
Legacy `dispatch_order_line_render.delay()` wurde nie durch Workflow-Engine ersetzt.
|
||||||
|
**Lösung:** `dispatch_render_with_workflow(order_line_id)` lädt `OutputType.workflow_definition_id`, nutzt Canvas wenn gesetzt, fällt sonst auf Legacy zurück.
|
||||||
|
|
||||||
|
### 2026-03-06 | OCC | `RWMesh_CoordinateSystemConverter` nicht als Python-Binding verfügbar
|
||||||
|
`writer.ChangeCoordinateSystemConverter()` → `TypeError: Unregistered type`.
|
||||||
|
**Lösung:** Shapes vor Export mit `BRepBuilderAPI_Transform(shape, trsf, True)` um Faktor 0.001 skalieren. `RWGltf_CafWriter` direkt ohne Koordinatensystem-Konverter aufrufen.
|
||||||
|
|
||||||
|
### 2026-03-06 | OCC | `XCAFDoc_ShapeTool.GetComponents` → `GetComponents_s`
|
||||||
|
In OCP alle XCAF static-Methoden mit `_s`-Suffix. Gilt für alle `XCAFDoc_*`-Klassen.
|
||||||
|
|
||||||
|
### 2026-03-06 | Pipeline | OCC-native STEP→GLB ersetzt STL-Intermediary
|
||||||
|
STL verliert Materialien/Farben, doppelter Aufwand, Dateiflut.
|
||||||
|
**Lösung:** `RWGltf_CafWriter` + `STEPCAFControl_Reader`: STEP→GLB direkt. `BRepMesh_IncrementalMesh` tesselliert vor Export. OCP (cadquery's Bindings, NICHT `OCC.Core`), statische Methoden mit `_s`-Suffix.
|
||||||
|
|
||||||
|
### 2026-03-06 | Celery | generate_gltf_geometry_task als Subprocess
|
||||||
|
OCP + bpy können nicht im selben Python-Prozess koexistieren (native C++ Konflikt).
|
||||||
|
**Lösung:** `export_step_to_gltf.py` via `subprocess.run([sys.executable, script, ...], timeout=120)` — OCC läuft isoliert.
|
||||||
|
|
||||||
|
### 2026-03-06 | OCC | Dihedralwinkel für sharp-edge Extraktion aus STEP
|
||||||
|
`topexp.MapShapesAndAncestors(shape, TopAbs_EDGE, TopAbs_FACE, map)` → Face-Paare pro Edge. `BRepAdaptor_Surface.DN()` für Normalen. Medianwinkel → `suggested_smooth_angle`, Midpunkte → `sharp_edge_midpoints` für KD-Tree in Blender. Nur im render-worker verfügbar.
|
||||||
|
|
||||||
|
### 2026-03-06 | Blender | UV-Seams aus sharp edges ableiten
|
||||||
|
Nach `edges_select_sharp(sharpness=radians(angle))` → `mark_sharp()` → `mark_seam(clear=False)`. Optional: bmesh KD-Tree für OCC-Midpoints (Toleranz 0.5mm vor scale(0.001)).
|
||||||
|
|
||||||
|
### 2026-03-06 | Multi-Tenancy | audit_log.tenant_id NOT NULL — Broadcast-Events
|
||||||
|
`emit_notification` setzt kein `tenant_id` → INSERT fehlschlägt → 500 bei Order-Submit.
|
||||||
|
**Lösung:** `tenant_id DROP NOT NULL`. Broadcast-Notifications (kein konkreter Tenant) brauchen nullable tenant_id.
|
||||||
|
|
||||||
|
### 2026-03-06 | Bbox | GLB statt STL für Bounding-Box Extraktion
|
||||||
|
`_bbox_from_stl()` obsolet nach Pipeline-Umbau.
|
||||||
|
**Lösung:** `_bbox_from_glb()` mit trimesh: `scene.bounds * 1000` für mm. Fallback auf `_bbox_from_step_cadquery()`.
|
||||||
|
|
||||||
|
### 2026-03-06 | Workflow | Turntable-Workflow brauchte step_path zur Laufzeit
|
||||||
|
`WorkflowDefinition.config` ist statisch → kein `step_path` → `ValueError`.
|
||||||
|
**Lösung:** `dispatch_render_with_workflow()` löst `step_path` + `output_dir` aus `OrderLine→Product→CadFile` auf und injiziert sie vor Dispatch. Statische (engine, samples) von laufzeit-abhängigen (Pfade, IDs) trennen.
|
||||||
|
|
||||||
|
### 2026-03-06 | Blender | Asset Library link=True — Assets müssen markiert sein
|
||||||
|
`bpy.data.libraries.load(blend_path, link=True, assets_only=True)` liefert nur via "Mark as Asset" markierte Datenblöcke.
|
||||||
|
**Lösung:** In .blend: jedes Material/Node-Group via "Mark as Asset" markieren. `catalog_assets.py` filtert via `m.asset_data is not None`.
|
||||||
|
|
||||||
|
### 2026-03-06 | Celery Inspect | active_queues() zum Worker-Capability-Check
|
||||||
|
`celery_app.control.inspect().active_queues()` zeigt pro Worker welche Queues er konsumiert. Besser als Worker-Namen-Heuristiken für `render_worker_connected`/`blender_available`-Detection.
|
||||||
|
|
||||||
|
### 2026-03-06 | TypeScript | Test-Dateien aus Haupt-tsconfig ausschließen
|
||||||
|
`vitest`/`msw`-Imports in `src/__tests__/` → TypeScript-Fehler in `tsc --noEmit` (Types nur im Test-Kontext).
|
||||||
|
**Lösung:** `"exclude": ["src/__tests__"]` in `tsconfig.json`. Vitest prüft eigene Typen intern.
|
||||||
|
|
||||||
|
### 2026-03-06 | ffmpeg | Turntable hängt ohne `shortest=1`
|
||||||
|
`lavfi color`-Stream hat unendliche Dauer → ffmpeg wartet nach PNG-Sequenz-Ende unbegrenzt.
|
||||||
|
**Lösung:** `overlay=0:0:shortest=1`. Bei ffmpeg-Overlays mit lavfi/nullsrc als Input IMMER `shortest=1`.
|
||||||
|
|
||||||
|
### 2026-03-06 | Architektur | WebSocket Auth via Query-Parameter (JWT)
|
||||||
|
Browser-WebSocket-API kann keine `Authorization`-Header senden.
|
||||||
|
**Lösung:** JWT als Query-Parameter: `ws://host/api/ws?token=<jwt>`. Token ist in Logs sichtbar — für v3: kurzlebigen WS-Token (TTL 30s) generieren.
|
||||||
|
|
||||||
|
### 2026-03-07 | Security | Media-Endpoints ohne Auth
|
||||||
|
`list_assets`, `download_asset`, `zip_download` ohne `get_current_user`-Dep → unauthentifizierte Requests. RLS schützt nur DB, nicht HTTP.
|
||||||
|
**Lösung:** `_user: User = Depends(get_current_user)` zu allen drei. Jeder neue Endpoint braucht expliziten Auth-Dep — RLS ist Defense-in-Depth, kein Ersatz.
|
||||||
|
|
||||||
|
### 2026-03-07 | MediaAsset | `is_animation` Flag entscheidet Asset-Type — falsches Design
|
||||||
|
`is_animation == True` setzte `asset_type = turntable` auch für `.jpg` Poster-Frames → Broken-Video-Icons.
|
||||||
|
**Lösung:** Extension entscheidet: `.mp4`/`.webm` → `turntable`, alles andere → `still`. MIME-Typ/Extension immer als primäre Typ-Quelle.
|
||||||
|
|
||||||
|
### 2026-03-07 | OCC | Bounding-Box aus STEP mit `Bnd_Box` + `brepbndlib.Add()`
|
||||||
|
```python
|
||||||
|
bbox = Bnd_Box(); brepbndlib.Add(shape, bbox)
|
||||||
|
xmin,ymin,zmin,xmax,ymax,zmax = bbox.Get()
|
||||||
|
```
|
||||||
|
Werte in mm (STEP-Einheit). In `mesh_attributes` JSONB speichern — kein neues DB-Feld nötig.
|
||||||
|
|
||||||
|
### 2026-03-07 | Storage | `storage_key` absolute Pfade brachen Volume-Moves
|
||||||
|
Absolute Pfade in `storage_key` → nach Volume-Umzug 398 Assets nicht erreichbar.
|
||||||
|
**Lösung:** `_normalize_key()` strippt `UPLOAD_DIR`-Prefix. Legacy-Remapping als Fallback. Neue Assets immer relativ: `renders/{uuid}/{filename}`.
|
||||||
|
|
||||||
|
### 2026-03-07 | SQLAlchemy Async | db.refresh() lädt keine Relationships
|
||||||
|
`await db.refresh(invoice)` lädt nur skalare Spalten → `invoice.lines` → lazy-load außerhalb Greenlet → `MissingGreenlet`, HTTP 500.
|
||||||
|
**Lösung:** Nach `db.commit()` separate select-Query mit `selectinload` statt `db.refresh()`.
|
||||||
|
|
||||||
|
### 2026-03-07 | Frontend Auth | Bearer-Token bei direktem Link-Download fehlt
|
||||||
|
`<a href="/api/billing/invoices/{id}/pdf">` sendet keinen Authorization-Header → 401.
|
||||||
|
**Lösung:** `api.get(..., { responseType: 'blob' })` → `URL.createObjectURL()` + programmatischer `<a>.click()`. Gilt für alle geschützten Download-Endpoints.
|
||||||
|
|
||||||
|
### 2026-03-07 | PostgreSQL RLS | SET LOCAL muss in jeder Transaktion gesetzt werden
|
||||||
|
`GRANT BYPASSRLS TO schaeffler` schlug still fehl → Admin-Endpoints bekamen 0 Zeilen.
|
||||||
|
**Lösung:** `await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))` direkt vor jede RLS-geschützte Query in internen/Admin-Endpoints.
|
||||||
|
|
||||||
|
### 2026-03-07 | trimesh | GLB-Export-Scale: STL in mm → Three.js in Metern
|
||||||
|
`mesh.apply_scale(0.001)` nach `trimesh.load()` vor Export. Bei `trimesh.Scene` über `scene.geometry.values()` iterieren. Optional: `trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)` für smooth normals.
|
||||||
|
|
||||||
|
### 2026-03-07 | React Dashboard | Responsive CSS-Grid mit matchMedia
|
||||||
|
Inline-Styles für Grid-Positioning haben keine Medienabfrage-Unterstützung.
|
||||||
|
**Lösung:** `useLargeScreen()` Hook mit `window.matchMedia('(min-width: 1024px)')` + Change-Listener → `isLarge`-Boolean bedingt Inline-Styles.
|
||||||
|
|
||||||
|
### 2026-03-07 | Media Browser | ZIP-Download 22-Byte-Korruption
|
||||||
|
`storage_key` absolute Pfade + `except Exception: pass` → Silent-Fail → leere Archive.
|
||||||
|
**Lösung:** Pfad-Check vor MinIO-Fallback; `except` loggt jetzt `logger.warning()`. In Generator-Funktionen für Streaming IMMER loggen.
|
||||||
|
|
||||||
|
### 2026-03-07 | Frontend | Fehlende React-Imports crashen die gesamte App
|
||||||
|
`useEffect` hinzugefügt ohne Import → `ReferenceError` → Blank Page. ErrorBoundary auf Root-Level fängt nicht ab.
|
||||||
|
**Regel:** Nach jedem neuen Hook sofort Import-Zeile prüfen. `tsc --noEmit` als Quality Gate im Container.
|
||||||
|
|
||||||
|
### 2026-03-07 | Storage Keys | Absolute Pfade in DB brechen nach Infrastruktur-Änderung
|
||||||
|
Flamenco-Outputs in `/shared/renders/...` → nach Flamenco-Entfernung 396 Assets nicht erreichbar (absoluter Pfad + MinIO-Fallback beide leer).
|
||||||
|
**Lösung:** Bulk-UPDATE auf relative Keys + Safety-Net-Remapping im Code. `storage_key` IMMER relativ zu `UPLOAD_DIR`: `renders/{uuid}/{filename}`.
|
||||||
|
|
||||||
|
### 2026-03-07 | Config | Pydantic Settings: Attributname case-sensitive
|
||||||
|
`settings.UPLOAD_DIR` → `AttributeError`. Korrekt: `settings.upload_dir` (lowercase wie in config.py).
|
||||||
|
**Smoke-Test:** `docker compose exec backend python -c "from app.config import settings; print(settings.upload_dir)"`.
|
||||||
|
|
||||||
|
### 2026-03-07 | Media ZIP | MIME-Type-basierte Extension → ".bin" statt ".png"
|
||||||
|
`(mime_type or "").split("/")[-1]` → `"bin"` für Assets ohne MIME-Type.
|
||||||
|
**Lösung:** Extension primär aus `Path(storage_key).suffix` lesen. MIME-Type nur als Fallback. Duplikat-Filenames mit `_1`, `_2` deduplizieren.
|
||||||
|
|
||||||
|
### 2026-03-07 | Blender 5.0 | `export_colors` in bpy.ops.export_scene.gltf entfernt
|
||||||
|
`export_colors=False` → `keyword unrecognized` → exit 1 → immer Trimesh-Fallback, nie Materialien.
|
||||||
|
**Lösung:** Parameter entfernen. Gültige Blender-5.0-Parameter: `export_format`, `export_apply`, `use_selection`, `export_materials`, `export_image_format`. Nach Blender-Major-Updates alle bpy.ops.*-Parameter prüfen.
|
||||||
|
|
||||||
|
### 2026-03-07 | React | useRef mit if(!ref.current) Guard reagiert nicht auf Prop-Änderungen
|
||||||
|
`GlbModel` klonte `scene` in `useRef` einmalig → bei neuem `url`-Prop blieb altes Mesh.
|
||||||
|
**Lösung:** `key={glbBlobUrl}` auf `<GlbModel>` → React remountet, frischer useRef. Alternativ: `useMemo` mit URL-Dependency.
|
||||||
|
|
||||||
|
### 2026-03-07 | React | staleTime zu hoch verzögert Erkennung neuer API-Daten
|
||||||
|
`staleTime: 30_000` + `qc.invalidateQueries()` → trotzdem 30s Verzögerung bis neue GLB-URL ankam.
|
||||||
|
**Lösung:** `staleTime: 0` für Queries die nach Mutations sofort aktuell sein müssen.
|
||||||
|
|
||||||
|
### 2026-03-07 | GLB Export | Trimesh kennt keine Materialien — Blender-Pipeline ist Pflicht
|
||||||
|
Trimesh = reine Geometrie, keine Material-Libraries, kein Asset-Library-Support → graue, facettierte GLBs.
|
||||||
|
**Lösung:** `generate_gltf_geometry_task` auf Blender headless (`export_gltf.py`) umgestellt. Trimesh nur als Fallback. Skript vorhanden ≠ Skript verdrahtet — Aufruf-Pfad immer prüfen.
|
||||||
|
|
||||||
|
### 2026-03-07 | Frontend | `<img src>` kann keine Auth-Header senden — useAuthBlob Hook nötig
|
||||||
|
`<img src="/api/media/{id}/download">` → kein Authorization-Header → 401 → graues Icon.
|
||||||
|
**Lösung:** `useAuthBlob(url, enabled)`: `fetch(url, { headers: { Authorization } })` → `URL.createObjectURL(blob)` als src. Cleanup via `revokeObjectURL` + `cancelled`-Flag. Gilt für alle browser-nativen Media-Elemente (`<img>`, `<video>`, `<audio>`).
|
||||||
|
|
||||||
|
### 2026-03-07 | Backend | publish_asset fehlte product_id + cad_file_id
|
||||||
|
`MediaAsset` ohne `product_id`/`cad_file_id` → `get_thumbnail_url()` konnte keinen Thumbnail-Fallback berechnen → graue Icons.
|
||||||
|
**Lösung:** In `publish_asset`: `Product` laden, `product_id=line.product_id` + `cad_file_id=product.cad_file_id` setzen. MediaAssets immer mit allen verfügbaren Referenz-FKs erstellen.
|
||||||
|
|
||||||
|
### 2026-03-07 | Frontend | Inline 3D Viewer — GLB mit Auth via Blob URL laden
|
||||||
|
`useGLTF(url)` aus `@react-three/drei` kann keine Auth-Header senden.
|
||||||
|
**Lösung:** `fetch(url, { headers: { Authorization } })` → `.blob()` → `URL.createObjectURL(blob)` → String-URL an `useGLTF(blobUrl)`. Revoke in useEffect-Cleanup. Three.js / drei kennen kein Auth-Konzept.
|
||||||
|
|
||||||
|
### 2026-03-07 | Backend | trimesh in optionalem [cad]-Extra — nicht im Docker-Build installiert
|
||||||
|
`pip install -e ".[dev]"` installiert kein `trimesh` → `ModuleNotFoundError` beim ersten Aufruf.
|
||||||
|
**Lösung:** `pip install -e ".[dev,cad]"`. Beim Hinzufügen optionaler Extras immer prüfen ob alle relevanten Container-Images das Extra installieren.
|
||||||
|
|
||||||
|
### 2026-03-07 | Frontend | URL.revokeObjectURL sofort nach click() → Race Condition
|
||||||
|
`revokeObjectURL(url)` synchron nach `a.click()` → Download manchmal leer (click() ist in manchen Browsern asynchron).
|
||||||
|
**Lösung:** `setTimeout(() => URL.revokeObjectURL(url), 100)` für alle programmatischen Blob-Downloads.
|
||||||
|
|
||||||
|
### 2026-03-07 | Media Import | Falsche asset_type-Klassifizierung durch Dateinamen-Matching
|
||||||
|
Dateiname enthielt "Turntable" → `asset_type=turntable` auch für `.jpg` Poster-Frames → Browser versuchte JPGs als `<video>` zu rendern.
|
||||||
|
**Lösung:** Daten-Fix: `UPDATE media_assets SET asset_type='still' WHERE asset_type='turntable' AND storage_key LIKE '%.jpg'`. Code-Fix: `isVideoAsset()` nutzt `mime_type` zusätzlich. Asset-Typ IMMER aus mime_type + Dateiendung, nie nur aus Dateiname.
|
||||||
|
|
||||||
|
### 2026-03-08 | Render | OptiX BVH cache ephemeral → first render slow after rebuild
|
||||||
|
Nach `docker compose up --build render-worker`: erstes Render 130–150s statt 22s. `/root/.nv/ComputeCache/` liegt im ephemeren Container-Dateisystem → wird bei jedem Rebuild geleert.
|
||||||
|
**Lösung:** Named volume: `optix-cache:/root/.nv` in docker-compose.yml. Detection: Render 1 >> Render 2+3 nach Rebuild → OptiX cold-start.
|
||||||
|
|
||||||
|
### 2026-03-10 | R3F | Invisible meshes: check `e.object.visible` in handlers causes regression
|
||||||
|
`mesh.visible = false` + Guard `if (!e.object.visible) return` in Event-Handlern blockiert alle Folge-Events — R3F's Event-Tracking feuert events mit `e.object` auf Meshes die nach dem Tracking unsichtbar wurden.
|
||||||
|
**Lösung:** `mesh.raycast = () => {}` wenn `mesh.visible = false`; restore mit `THREE.Mesh.prototype.raycast` beim Einblenden. Meshes vom Raycasting ausschließen an der Quelle, nicht im Handler.
|
||||||
|
**Regel:** Nie `e.object.visible` in R3F pointer/click-Handlern prüfen.
|
||||||
|
|
||||||
|
### 2026-03-10 | Three.js/GLTF | `mesh.name` wird durch sanitizeNodeName verändert — `mesh.userData.name` nutzen
|
||||||
|
Three.js `GLTFLoader.createUniqueName()` ruft `PropertyBinding.sanitizeNodeName()` auf, welches reservierte Zeichen `[]:./` **entfernt**. Blender-Dedup-Suffixe `.001`, `.012` etc. werden zu `001`, `012` — ohne Punkt.
|
||||||
|
Unser `normalizeMeshName` strippte `\.\d{3}$` (mit Punkt), aber der Punkt ist bereits weg → `GE360-HF-0051-EIN_1.012` → (Three.js sanitize) → `GE360-HF-0051-EIN_1012` → kein Match in partMaterials → fälschlich "unassigned".
|
||||||
|
**Fix:** `mesh.userData.name` verwenden statt `mesh.name`. Three.js speichert den Original-GLTF-Node-Namen (unsanitized, mit Punkten) in `node.userData.name = nodeDef.name` **bevor** `node.name = sanitize(...)` gesetzt wird. Pattern: `(mesh.userData?.name as string) || mesh.name`.
|
||||||
|
**Gilt für:** handleClick, handlePointerOver/Out, visibility/glow/color effects, onReady callback, glbMeshNames-Traversal in InlineCadViewer + ThreeDViewer.
|
||||||
|
|
||||||
|
### 2026-03-10 | OCC/GLTF | Assembly containers have no geometry → no mesh in GLB
|
||||||
|
`cad_part_materials` enthält Keys für Assembly-Nodes (`_ASM_1`, `_BASIS_ASM_1`), die in cadquery geparst werden, aber keine Geometrie haben → nicht im GLB. Debug-Log "matched: 4 of 9 stored keys" = 5 Keys sind Assembly-Container, kein Bug.
|
||||||
|
**Regel:** Für assigned/total-Zählung immer `glbMeshNames` (Set aus scene traversal) nutzen, nie `Object.keys(partMaterials).length`.
|
||||||
|
|
||||||
|
### 2026-03-10 | Debug-Workflow | GLB-Geometrie-Analyse direkt per Python — ohne Browser-DevTools
|
||||||
|
Bei Mesh-Name-Mismatch-Bugs: GLB-Datei direkt parsen statt im Browser debuggen. Spart viele Iterationen.
|
||||||
|
|
||||||
|
**Schritt 1 — GLB-Download-URL holen:**
|
||||||
|
```python
|
||||||
|
import urllib.request, json, struct
|
||||||
|
# Login
|
||||||
|
data = json.dumps({'email':'admin@schaeffler.com','password':'Admin1234!'}).encode()
|
||||||
|
req = urllib.request.Request('http://localhost:8888/api/auth/login', data=data, headers={'Content-Type':'application/json'})
|
||||||
|
token = json.load(urllib.request.urlopen(req))['access_token']
|
||||||
|
# Media-Assets für CAD-File
|
||||||
|
assets = json.load(urllib.request.urlopen(urllib.request.Request(
|
||||||
|
f'http://localhost:8888/api/media/?cad_file_id={CAD_FILE_ID}&asset_types=gltf_production&asset_types=gltf_geometry',
|
||||||
|
headers={'Authorization': f'Bearer {token}'})))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 2 — GLB parsen und Mesh-/Node-Namen ausgeben:**
|
||||||
|
```python
|
||||||
|
req = urllib.request.Request(DOWNLOAD_URL, headers={'Authorization': f'Bearer {token}'})
|
||||||
|
glb = urllib.request.urlopen(req).read()
|
||||||
|
chunk_len, _ = struct.unpack('<II', glb[12:20])
|
||||||
|
gltf = json.loads(glb[20:20+chunk_len])
|
||||||
|
nodes, meshes = gltf['nodes'], gltf['meshes']
|
||||||
|
for n in nodes:
|
||||||
|
if n.get('mesh') is not None:
|
||||||
|
print(f"node={repr(n['name'])} mesh_def={repr(meshes[n['mesh']]['name'])}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 3 — normalizeMeshName + resolvePartMaterial in Python nachbauen und alle Namen durchlaufen:**
|
||||||
|
So lässt sich der vollständige Matching-Status (ASSIGNED/UNASSIGNED) offline prüfen, ohne den Browser zu öffnen. Ggf. gespeicherte `cad_part_materials` via `GET /api/cad/{id}/part-materials` dazuladen.
|
||||||
|
|
||||||
|
**Produkt-ID → CAD-File-ID:** `GET /api/products/{product_id}` → Feld `cad_file_id`.
|
||||||
|
**CAD-File-ID für Media-Assets:** `GET /api/media/?cad_file_id={id}&asset_types=gltf_production` (nutzt `listMediaAssets`, nicht `getMediaAssets`).
|
||||||
|
|
||||||
|
### 2026-03-11 | Render-Pipeline | Production GLB: Koordinaten OCC→Blender falsch
|
||||||
|
OCC STEP-Koordinaten (Z-up, mm) werden von `export_step_to_gltf.py` auf Y-up (glTF) rotiert, dann vom Blender-Importer wieder auf Z-up. Netto: `Blender(X, Y, Z) = OCC(X×0.001, -Z×0.001, Y×0.001)` — Y und Z tauschen plus Z-Negierung. Einfach mm→m skalieren (X=X, Y=Y, Z=Z) ist FALSCH.
|
||||||
|
**Lösung:** In allen `_apply_sharp_edges_from_occ()`-Funktionen: `v = Vector((p[0]*0.001, -p[2]*0.001, p[1]*0.001))`.
|
||||||
|
**Diagnose:** `DEBUG first_vert_local=[0.0, -0.1175, 0.26]` vs `occ_pair=[0.0, 0.26, 0.1175]` zeigte eindeutig Y↔Z-Swap + Z-Negierung.
|
||||||
|
|
||||||
|
### 2026-03-11 | Render-Pipeline | Production GLB: Mesh-Data-Sharing bei Material-Zuweisung
|
||||||
|
Nach GLB-Import teilen sich mehrere Blender-Objekte denselben Mesh-Datablock (z.B. Instanzen). `obj.data.materials.clear()` auf geteilten Daten löscht Materialien für ALLE Objekte, die diesen Mesh teilen.
|
||||||
|
**Lösung:** Vor `obj.data.materials.clear()` immer: `if obj.data.users > 1: obj.data = obj.data.copy()` (single-user machen).
|
||||||
|
|
||||||
|
### 2026-03-11 | Render-Pipeline | Production GLB: Name-Mismatch cadquery vs. RWGltf
|
||||||
|
cadquery-Parser und RWGltf_CafWriter lesen dieselbe STEP-Datei, erzeugen aber unterschiedliche Part-Namen (z.B. cadquery: `GE360-HF-0011-EIN_HAELFTE_AF0_1_AF0` vs. RWGltf: `GE360-HF-0021-EIN`). Material-Mapping via Namen deckt nur 2/25 Objekte ab.
|
||||||
|
**Lösung 1:** Prefix-Match: `key.startswith(lower_base)` oder `lower_base.startswith(key)`.
|
||||||
|
**Lösung 2:** Single-Material-Fallback: wenn `len(appended) == 1` (nur 1 Bibliotheks-Material geladen), alle ungematchten Objekte ebenfalls mit diesem Material belegen.
|
||||||
|
**Generell:** Part-Namen aus zwei verschiedenen OCC-Lese-Pfaden NIE direkt vergleichen.
|
||||||
|
|
||||||
|
### 2026-03-11 | Python/Blender | `id(bpy_object)` über Iterationen hinweg unzuverlässig
|
||||||
|
Blender-Objekte sind Python-Wrapper über C++-Objekte. Nach einer Schleife können gleiche C++-Objekte neue Python-Wrapper bekommen → `id()` ändert sich. Tracking mit `id(obj)` in einem Set und Prüfung in einer zweiten Schleife funktioniert NICHT.
|
||||||
|
**Lösung:** `obj.name` als stabiler Identifier (Blender-Namen sind innerhalb der Szene eindeutig und bleiben stabil).
|
||||||
|
|
||||||
|
### 2026-03-11 | Python/Blender | `bmesh.edges.get((v0, v1))` schlägt fehl bei idx0==idx1
|
||||||
|
Wenn KD-Tree für beide Endpunkte denselben Vertex findet (z.B. degenerate/zero-length edge), wirft `edges.get((v, v))` `ValueError: found the same (BMVert) used multiple times`.
|
||||||
|
**Lösung:** Guard: `if idx0 == idx1: continue` direkt nach dem `kd.find()` für Endpunkt 2.
|
||||||
|
|
||||||
|
### 2026-03-11 | Workflow | System-Daten direkt lesen — kein docker exec nötig
|
||||||
|
Für den `/plan`-Agent: alle relevanten System-Daten sind OHNE docker-Befehle zugänglich:
|
||||||
|
- **Datenbankmodelle:** `backend/app/models/` und `backend/app/domains/*/models.py` direkt lesen
|
||||||
|
- **API-Endpoints:** `backend/app/api/routers/` und `backend/app/domains/*/routers.py` lesen — kein curl/docker exec nötig
|
||||||
|
- **Celery-Tasks:** `backend/app/tasks/step_tasks.py` und `backend/app/domains/pipeline/tasks/` lesen
|
||||||
|
- **Render-Scripts:** `render-worker/scripts/` lesen — `blender_render.py`, `export_gltf.py` etc.
|
||||||
|
- **Frontend-API:** `frontend/src/api/*.ts` zeigt alle API-Calls und Typen
|
||||||
|
- **Gespeicherte Daten:** `CadFile.mesh_attributes`, `Product.cad_part_materials`, `SystemSetting` in Modellen beschrieben
|
||||||
|
- **Docker-Logs nur für Debugging:** `docker compose logs --since 5m render-worker 2>&1 | grep -E "pattern"` — sparsam einsetzen, nicht für Codeanalyse
|
||||||
|
- **Kein `docker exec backend python3 -c`** für Code-Exploration — dauert lang und braucht laufende Container
|
||||||
|
- **MEMORY.md** enthält Container-Capabilities: OCP nur in `render-worker`, nicht in `worker` oder `backend`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Offene Fragen
|
## Offene Fragen
|
||||||
- [ ] Azure AI Credentials für Phase 4 (Bildvalidierung) noch nicht konfiguriert
|
- [ ] Azure AI Credentials für Phase 4 (Bildvalidierung) noch nicht konfiguriert
|
||||||
@@ -297,226 +403,3 @@ __all__ = ["User"]
|
|||||||
- [ ] @xyflow/react noch nicht installiert — npm install nötig nach nächstem `docker compose up --build frontend`
|
- [ ] @xyflow/react noch nicht installiert — npm install nötig nach nächstem `docker compose up --build frontend`
|
||||||
- [ ] Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab
|
- [ ] Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab
|
||||||
- [ ] Turntable-Animation: bg_color via FFmpeg-Overlay — Qualität bei Transparenz-Edges prüfen
|
- [ ] Turntable-Animation: bg_color via FFmpeg-Overlay — Qualität bei Transparenz-Edges prüfen
|
||||||
|
|
||||||
### 2026-03-06 | Docker | apt-Paketname libgdk-pixbuf2.0-0 vs libgdk-pixbuf-2.0-0
|
|
||||||
WeasyPrint benötigt libgdk-pixbuf. Auf Debian bookworm (python:3.11-slim) heißt das Paket `libgdk-pixbuf-2.0-0` (mit Bindestrichen), nicht `libgdk-pixbuf2.0-0`. `apt-get install` schlägt mit exit code 100 fehl wenn der Name falsch ist.
|
|
||||||
→ Immer `apt-cache search libgdk` im Container prüfen bevor man Paketnamen in Dockerfiles schreibt.
|
|
||||||
|
|
||||||
### 2026-03-06 | Celery | thumbnail_rendering Queue braucht eigenen worker-thumbnail Service
|
|
||||||
Blender-Renderer verarbeitet nur 1 Request gleichzeitig. Wenn worker (concurrency=8) Tasks auf thumbnail_rendering queued, laufen 7 davon in Timeout (300s). Lösung: separaten `worker-thumbnail` Service mit `--concurrency=1` und `-Q thumbnail_rendering` in docker-compose.yml. step_processing bleibt bei concurrency=8.
|
|
||||||
|
|
||||||
### 2026-03-06 | Alembic | Migration exit code 100 bei enum-Konflikt
|
|
||||||
SQLAlchemy `Enum(create_type=False)` funktioniert nicht zuverlässig mit asyncpg. Bei bereits existierenden PostgreSQL-Enum-Typen: Raw SQL mit `DO $$ BEGIN CREATE TYPE ...; EXCEPTION WHEN duplicate_object THEN NULL; END $$;` verwenden. Für Tabellen: `CREATE TABLE IF NOT EXISTS`.
|
|
||||||
|
|
||||||
### 2026-03-06 | Render-Pipeline | Circular Shim blockiert alle Order-Renders
|
|
||||||
**Problem:** `dispatch_order_line_render` → `dispatch_render` (Shim A→B→A Circular Import) → Render startet nie. Die einzige funktionierende Render-Implementierung `render_order_line_task` war nie aus dem Dispatch-Chain erreichbar.
|
|
||||||
**Lösung:** `dispatch_order_line_render` direkt auf `render_order_line_task.delay()` umleiten. `render_dispatcher.py`-Shim ebenfalls repariert. Dispatch-Service `_legacy_dispatch` ebenfalls auf `render_order_line_task` umgeleitet.
|
|
||||||
**Erkenntnisse:** Bei Refactoring immer prüfen ob Shims zirkulär werden. Wenn zwei Module sich gegenseitig importieren (A→B und B→A), entsteht ein Circular Import — keine echte Implementierung wird aufgerufen. Den echten Aufruf-Pfad von der API zum Task vor Refactoring dokumentieren.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Render-Pipeline | render_order_line_task auf falschem Worker (kein Blender)
|
|
||||||
**Problem:** `render_order_line_task` war auf Queue `step_processing` → lief im `worker`-Container (Backend-Dockerfile, kein Blender). `render_to_file()` fiel still auf Pillow-Placeholder zurück. Renders scheinbar erfolgreich aber nur graue Platzhalterbilder.
|
|
||||||
**Ursache:** `is_blender_available()` prüft `BLENDER_BIN`-Env-Var — im `worker`-Container nicht gesetzt. Fallback auf Pillow passiert lautlos ohne Exception.
|
|
||||||
**Lösung:** `render_order_line_task` queue auf `thumbnail_rendering` geändert → läuft jetzt im `render-worker`-Container (hat Blender 5.0.1 + cadquery). `worker-thumbnail`-Service aus `docker-compose.yml` entfernt (hatte keinen Blender, blockierte aber die Queue).
|
|
||||||
**Für künftige Projekte:** Blender-Tasks IMMER auf `thumbnail_rendering` Queue routen. `worker-thumbnail` = kein Blender, `render-worker` = hat Blender. Wenn `is_blender_available()` False zurückgibt ist der Task auf dem falschen Worker.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Docker | worker-thumbnail vs render-worker — beide auf thumbnail_rendering
|
|
||||||
**Problem:** Sowohl `worker-thumbnail` (kein Blender) als auch `render-worker` (hat Blender) lauschten auf `thumbnail_rendering` Queue. Tasks wurden round-robin verteilt → 50% der Blender-Tasks schlugen fehl (Pillow-Fallback, kein echter Fehler).
|
|
||||||
**Lösung:** `worker-thumbnail`-Service aus docker-compose entfernt. `render-worker` ist der alleinige Consumer von `thumbnail_rendering`. Dieser hat Blender + cadquery + alle Render-Scripts.
|
|
||||||
**Für künftige Projekte:** Nie zwei Services mit unterschiedlichen Capabilities auf die gleiche Queue hören lassen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Multi-Tenancy | tenant_id NOT NULL verletzt bei Order-Erstellung
|
|
||||||
**Problem:** Migration 036 machte `tenant_id NOT NULL` auf `orders`, `order_lines`, `order_items`. Alle Create-Endpoints übergaben `tenant_id` nicht → PostgreSQL NOT NULL Constraint Violation.
|
|
||||||
**Lösung:** Überall `tenant_id=getattr(user, 'tenant_id', None)` in Model-Konstruktoren: `orders.py` (create_order, split_order, add_line_to_order), `uploads.py` (finalize_excel).
|
|
||||||
**Für künftige Projekte:** Nach jeder RLS-Migration alle Create-Endpoints prüfen ob das neue Pflichtfeld befüllt wird. `getattr(user, 'tenant_id', None)` als sicheres Default-Pattern verwenden.
|
|
||||||
|
|
||||||
### 2026-03-06 | Celery | render_order_line_task auf falscher Queue → Pillow-Fallback
|
|
||||||
**Problem:** `render_order_line_task` war auf `step_processing` Queue → wurde von `worker`-Container bearbeitet, der kein Blender hat. `is_blender_available()` → False → Pillow-Placeholder-Bild ohne Fehlermeldung.
|
|
||||||
**Lösung:** Queue zu `thumbnail_rendering` geändert → nur `render-worker` (mit Blender 5.0.1) verarbeitet diese Tasks.
|
|
||||||
**Für künftige Projekte:** Nach jeder Architektur-Änderung (Container-Entfernung, Queue-Umbenennung) alle Celery-Task-Dekoratoren prüfen ob sie noch auf dem richtigen Worker laufen.
|
|
||||||
|
|
||||||
### 2026-03-06 | Celery | Zwei Worker auf derselben Queue mit unterschiedlichen Fähigkeiten
|
|
||||||
**Problem:** `worker-thumbnail` und `render-worker` konkurrierten auf `thumbnail_rendering`. `worker-thumbnail` hatte kein Blender → 50% aller Render-Tasks liefen auf dem falschen Worker → Silent-Fail.
|
|
||||||
**Lösung:** `worker-thumbnail` aus docker-compose.yml entfernt. `render-worker` ist einziger Consumer von `thumbnail_rendering`.
|
|
||||||
**Regel:** Jede Queue sollte nur von Workers mit identischen Fähigkeiten konsumiert werden. Nie zwei Worker unterschiedlicher Ausstattung auf dieselbe Queue setzen.
|
|
||||||
|
|
||||||
### 2026-03-06 | Python | Circular Import via doppelte Shim-Schicht
|
|
||||||
**Problem:** `template_service.py` importierte aus `domains/rendering/service.py`, das wiederum aus `template_service.py` importierte. Beide waren leere Shims. `resolve_template()` war nie aufrufbar → Render-Tasks crashing mit ImportError.
|
|
||||||
**Lösung:** Volle Implementierung in `template_service.py` wiederhergestellt (aus git history). `domains/rendering/service.py` importiert nur davon — kein Rückimport.
|
|
||||||
**Für künftige Projekte:** Shim-Layer immer auf circular imports prüfen. `domains/X/service.py` sollte entweder die echte Implementierung enthalten ODER aus einer anderen Domain importieren, aber nicht im Kreis.
|
|
||||||
|
|
||||||
### 2026-03-06 | FastAPI | 307-Redirect verliert Authorization-Header
|
|
||||||
**Problem:** `GET /api/tenants` → 307 Temporary Redirect zu `/api/tenants/` (trailing slash). axios folgt dem Redirect, verliert dabei den Authorization-Header → 401 → leere Tenant-Liste im Frontend.
|
|
||||||
**Lösung:** Frontend-API-Call auf `/tenants/` mit trailing slash geändert.
|
|
||||||
**Für künftige Projekte:** FastAPI-Router immer mit trailing slash aufrufen oder `redirect_slashes=False` am Router setzen.
|
|
||||||
|
|
||||||
### 2026-03-06 | Render-Pipeline | ffmpeg Turntable hängt ohne `shortest=1`
|
|
||||||
**Problem:** Turntable-Render (Order f0436188) mit bg_color schlug mit Timeout (300s) fehl. ffmpeg-Overlay-Befehl war `[1:v][0:v]overlay=0:0` — der `lavfi color`-Quell-Stream hat unendliche Dauer. ffmpeg wartete nach Ende der PNG-Sequenz weiter auf weitere Farb-Stream-Frames → hing unbegrenzt.
|
|
||||||
**Lösung:** `overlay=0:0` → `overlay=0:0:shortest=1`. `shortest=1` beendet den Output-Stream sobald der kürzeste Input-Stream endet (die PNG-Sequenz).
|
|
||||||
**Datei:** `backend/app/services/render_blender.py:507`
|
|
||||||
**Für künftige Projekte:** Bei ffmpeg-Overlays mit lavfi/color/nullsrc als ein Input IMMER `shortest=1` setzen. Sonst hängt ffmpeg nach Ende des finite Streams.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Architektur | WebSocket Auth via Query-Parameter (JWT)
|
|
||||||
**Problem:** WebSocket-Verbindungen können keinen `Authorization`-Header senden (Browser-WebSocket-API hat keine Header-Unterstützung). JWT muss anders übertragen werden.
|
|
||||||
**Lösung:** JWT als Query-Parameter: `ws://host/api/ws?token=<jwt>`. Backend verifiziert via `jwt.decode()` im WebSocket-Endpoint.
|
|
||||||
**Sicherheitshinweis:** Token ist in Server-Logs sichtbar. Für v2 akzeptabel. In v3: kurzlebigen WS-Token (TTL 30s) aus JWT generieren.
|
|
||||||
**Für künftige Projekte:** Immer Query-Param oder Cookie (bei HTTPS) für WebSocket-Auth verwenden; nie erwarten dass der Browser Headers setzen kann.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Blender | Asset Library link=True — Assets müssen in .blend als Asset markiert sein
|
|
||||||
**Problem:** `bpy.data.libraries.load(blend_path, link=True, assets_only=True)` liefert nur Materialien/Node-Groups die explizit via Blender's Asset-System markiert wurden (`asset_data is not None`). Nicht markierte Datenblöcke werden ignoriert.
|
|
||||||
**Lösung:** In der .blend-Datei: jedes Material/Node-Group das gelinkt werden soll muss via "Mark as Asset" (F3 → "Mark as Asset") markiert sein.
|
|
||||||
**catalog_assets.py** filtert via `m.asset_data is not None` — dieser Filter muss konsistent in catalog_assets.py und asset_library.py verwendet werden.
|
|
||||||
**Für künftige Projekte:** Immer "Mark as Asset" dokumentieren wenn .blend-Libraries an User weitergegeben werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | Celery Inspect | active_queues() zum Worker-Capability-Check
|
|
||||||
**Erkenntnis:** `celery_app.control.inspect().active_queues()` gibt pro Worker zurück welche Queues er konsumiert. Damit kann man gezielt prüfen ob ein Worker mit bestimmten Fähigkeiten (z.B. `thumbnail_rendering`) connected ist — besser als Worker-Namen-Heuristiken.
|
|
||||||
**Anwendung:** `GET /api/worker/health/render` nutzt `active_queues()` um `render_worker_connected` und `blender_available` korrekt zu bestimmen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | SQLAlchemy Async | db.refresh() lädt keine Relationships
|
|
||||||
**Problem:** `create_invoice` rief `await db.refresh(invoice)` — lädt nur skalare Spalten, nicht `invoice.lines` (Relationship). FastAPI serialisiert danach `lines` → SQLAlchemy versucht lazy-load außerhalb eines Greenlets → `MissingGreenlet`-Exception, HTTP 500.
|
|
||||||
**Lösung:** Statt `db.refresh()` die bestehende `get_invoice(db, invoice.id)` Funktion aufrufen, die `selectinload(Invoice.lines)` verwendet und alle Relationships korrekt vorlädt.
|
|
||||||
**Regel:** Nach `db.commit()` in Diensten die Relationships brauchen, immer eine separate select-Query mit `selectinload` machen anstatt `db.refresh()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Frontend Auth | Bearer-Token bei direktem Link-Download fehlt
|
|
||||||
**Problem:** `<a href="/api/billing/invoices/{id}/pdf" target="_blank">` öffnet den Link direkt im Browser ohne `Authorization`-Header → Backend gibt 401/403 zurück.
|
|
||||||
**Lösung:** API-Call via `api.get(..., { responseType: 'blob' })` (axios-Client mit automatischem Auth-Header), dann `URL.createObjectURL()` + programmatischer `<a>.click()`. So geht der Auth-Token mit.
|
|
||||||
**Gilt für:** Alle geschützten Download-Endpoints (PDFs, ZIPs, etc.) die via direkten Link nicht erreichbar sind.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-06 | TypeScript | Test-Dateien aus Haupt-tsconfig ausschließen
|
|
||||||
**Problem:** `vitest`- und `msw`-Imports in `src/__tests__/` erzeugen TypeScript-Fehler in `tsc --noEmit` weil diese Packages ihre Typen nur im Test-Kontext (über vitest globals) bereitstellen. `tsc` kennt die Types nicht, obwohl die Packages installiert sind.
|
|
||||||
**Lösung:** In `tsconfig.json` ein `"exclude": ["src/__tests__"]` hinzufügen. Vitest führt seine eigene Typ-Prüfung durch; der Haupt-Build braucht nur Produktionscode zu prüfen.
|
|
||||||
**Für künftige Projekte:** Test-Verzeichnisse immer aus der Haupt-tsconfig ausschließen und eine separate `tsconfig.test.json` oder Vitest-interne Typ-Prüfung nutzen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | PostgreSQL RLS | SET LOCAL muss in jeder Transaktion erneut gesetzt werden
|
|
||||||
**Problem:** `GRANT BYPASSRLS TO schaeffler` in Migration 036 schlug still fehl (schaeffler ist kein Superuser). Alle Endpoints die `cad_files`, `order_lines`, `products` abfragen (z.B. `import_existing_media_assets`, `get_thumbnail`, `_resolve_thumbnails_bulk`) erhielten durch RLS 0 Zeilen zurück → Media-Browser leer, Thumbnails fehlten.
|
|
||||||
**Lösung:** `await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'"))` direkt vor jede RLS-geschützte Query in internen/admin Endpoints setzen. `SET LOCAL` wirkt nur für die aktuelle Transaktion — reicht für async SQLAlchemy (gleiche Session = gleiche Transaktion).
|
|
||||||
**Regel:** Jeder interne Endpoint der ohne User-Auth-Kontext RLS-Tabellen liest braucht expliziten `SET LOCAL`-Bypass. BYPASSRLS-Grant an App-User ist kein sicherer Weg.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | trimesh | GLB-Export-Scale: STL in mm → Three.js in Metern
|
|
||||||
**Problem:** STL-Cache enthält Vertices in Millimetern (STEP-Standard). trimesh exportiert ohne Skalierung → Three.js liest GLB in Metern → Objekte 1000× zu groß.
|
|
||||||
**Lösung:** `mesh.apply_scale(scale_factor)` (default 0.001) nach `trimesh.load()` vor Export. Bei `trimesh.Scene` über `scene.geometry.values()` iterieren; bei einzelnem `Trimesh` direkt anwenden.
|
|
||||||
**Auch:** `trimesh.smoothing.filter_laplacian(mesh, lamb=0.5, iterations=5)` für smooth normals (STL speichert nur Facet-Normals → facettiertes Aussehen ohne Smoothing).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | React Dashboard | Responsive CSS-Grid mit matchMedia
|
|
||||||
**Problem:** CSS Grid mit `gridColumnStart/End/RowStart/End` per Inline-Style lässt sich nicht mit Tailwind-Breakpoints kombinieren — Inline-Styles haben keine Medienabfrage-Unterstützung.
|
|
||||||
**Lösung:** Custom Hook `useLargeScreen()` mit `window.matchMedia('(min-width: 1024px)')` + Change-Listener. `isLarge`-Boolean bedingt die Inline-Styles: Auf großen Screens: Grid-Positioning aktiv; auf kleinen Screens: leeres Style-Objekt → natürlicher Flow (Widgets stacken).
|
|
||||||
**Regel:** Wenn CSS-Grid-Positioning über Inline-Styles kommt (z.B. aus DB-Konfiguration), immer matchMedia-Hook zur responsiven Steuerung verwenden statt CSS-only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Media Browser | ZIP-Download 22-Byte-Korruption
|
|
||||||
**Problem:** ZIP-Download-Endpoint lieferte 22-Byte-leere Archive. Ursache: `storage_key` enthielt absolute Pfade (z.B. `/shared/renders/...`). `except Exception: pass` im Generator schluckte den Fehler still.
|
|
||||||
**Lösung:** Pfad-Check vor MinIO-Fallback: `Path(key)` prüfen ob absolut; falls nicht → relativ zu `UPLOAD_DIR`. `candidate.exists()` → `read_bytes()`. `except` loggt jetzt `logger.warning()` statt silent pass.
|
|
||||||
**Regel:** In Generator-Funktionen für Streaming-Responses IMMER loggen — silent pass führt zu korrupten Archiven ohne sichtbaren Fehler.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Frontend | Fehlende React-Imports crashen die gesamte App (Blank Page)
|
|
||||||
**Problem:** `useEffect` in `useLargeScreen()` Hook hinzugefügt, aber `import { useState } from 'react'` nicht auf `import { useState, useEffect } from 'react'` erweitert. Vite/React wirft zur Laufzeit `ReferenceError: useEffect is not defined` → ErrorBoundary auf Root-Level fängt nicht ab → gesamte React-App zeigt leere Seite.
|
|
||||||
**Warum /check es nicht gefangen hat:** `/check` rief `npm test` und `npm run lint` auf — kein `lint`-Script vorhanden, kein TypeScript-Compiler (`tsc`) in `node_modules` lokal (Deps nur in Docker). `npm test` (Vitest) lief für Test-Dateien, prüfte aber keine Production-Komponenten auf fehlende Imports.
|
|
||||||
**Lösung:** `useEffect` zum Import hinzugefügt. **Langfristig:** `tsc --noEmit` als Quality Gate im Container ausführen.
|
|
||||||
**Regel:** Nach jedem neuen React-Hook oder neuer API (`useEffect`, `useCallback`, `useRef` etc.) sofort prüfen ob der Import oben in der Datei ergänzt wurde.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Storage Keys | Absolute Pfade in DB brechen nach Infrastruktur-Änderung
|
|
||||||
**Problem:** Flamenco schrieb Render-Outputs nach `/shared/renders/{uuid}/{file}`. Nach Flamenco-Entfernung wurden die Dateien in `/app/uploads/renders/` kopiert, aber die `storage_key`-Werte in `media_assets` blieben auf `/shared/renders/...`. Der `download_asset`-Endpoint suchte den absoluten Pfad (existiert nicht) und fiel auf MinIO zurück (auch nicht vorhanden) → HTTP 404 für 396 Blender-Renders.
|
|
||||||
**Lösung:**
|
|
||||||
1. Bulk-UPDATE: `UPDATE media_assets SET storage_key = 'renders/{uuid}/{file}' WHERE storage_key LIKE '/shared/renders/%'` (nur für Dateien die am neuen Pfad existieren)
|
|
||||||
2. Safety-Net im Code: Wenn absoluter Pfad nicht existiert und `/shared/renders/` enthält → automatisch auf `UPLOAD_DIR/renders/` remappen
|
|
||||||
3. `settings.UPLOAD_DIR` war falsch (Pydantic-Setting heißt `upload_dir` lowercase) — ebenfalls behoben
|
|
||||||
**Regelung:** `storage_key` in MediaAssets IMMER relativ zu `UPLOAD_DIR` speichern, nie als absoluten Pfad. Format: `renders/{uuid}/{filename}` oder `thumbnails/{uuid}/{filename}`. Absolute Pfade brechen bei jedem Container-Rebuild oder Volume-Umzug.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Config | Pydantic Settings: Attributname case-sensitive
|
|
||||||
**Problem:** `settings.UPLOAD_DIR` warf `AttributeError` — Pydantic-Settings-Objekte sind case-sensitive. Das korrekte Attribut heißt `upload_dir` (lowercase, wie in config.py definiert).
|
|
||||||
**Lösung:** Alle Zugriffe auf `settings.UPLOAD_DIR` → `settings.upload_dir` korrigiert.
|
|
||||||
**Quality Gate:** `docker compose exec backend python -c "from app.config import settings; print(settings.upload_dir)"` als Smoke-Test für Config-Zugriff.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Media ZIP | MIME-Type-basierte Extension → ".bin" statt ".png"
|
|
||||||
**Problem:** `zip_download` ermittelte Datei-Extension via `(a.mime_type or "").split("/")[-1] or "bin"`. Für Assets mit `mime_type=None` (importierte Flamenco-Renders) → Extension `"bin"` → Dateien im ZIP als `.bin` statt `.png`/`.jpg` — ZIP öffnet, aber keine Bilder erkennbar.
|
|
||||||
**Lösung:** Extension primär aus `Path(storage_key).suffix` lesen — der storage_key enthält immer die echte Datei-Extension. MIME-Type nur als Fallback. Zusätzlich: Original-Dateiname aus `storage_key` statt generischem `{type}_{uuid}.{ext}` verwenden. Duplikat-Filenames (mehrere Assets mit gleichem Dateinamen) werden mit `_1`, `_2` Suffix dedupliziert.
|
|
||||||
**Regel:** Datei-Erweiterung IMMER aus dem tatsächlichen Dateinamen (storage_key) lesen, nie nur aus MIME-Type. MIME-Types können null sein oder nicht dem tatsächlichen Format entsprechen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Blender 5.0 | `export_colors` in bpy.ops.export_scene.gltf entfernt
|
|
||||||
**Problem:** `bpy.ops.export_scene.gltf(export_colors=False)` → `keyword "export_colors" unrecognized` → exit code 1 → immer Trimesh-Fallback → nie Materialien, nie Sharp Edges, immer facettiert. Blender hat nie erfolgreich exportiert.
|
|
||||||
**Lösung:** `export_colors` aus dem Export-Call entfernen. Gültige Blender-5.0-Parameter: `export_format`, `export_apply`, `use_selection`, `export_materials`, `export_image_format`.
|
|
||||||
**Regel:** Beim Wechsel auf neue Blender-Versionen alle bpy.ops.*-Parameter gegen aktuelle Blender-Doku prüfen. Fehlende Parameter lassen Blender mit exit 1 fehlschlagen — OHNE aussagekräftige Fehlermeldung im Erfolgsfall.
|
|
||||||
|
|
||||||
### 2026-03-07 | React | useRef mit if(!ref.current) Guard reagiert nicht auf Prop-Änderungen
|
|
||||||
**Problem:** `GlbModel` klonte `scene` in `useRef` mit `if (!cloned.current)`. Bei neuem `url`-Prop (regeneriertes GLB) blieb `cloned.current` das alte geklonte Objekt → altes Mesh wurde weiter angezeigt. React mounted/unmounts die Komponente nicht bei Prop-Änderungen.
|
|
||||||
**Lösung:** `key={glbBlobUrl}` auf `<GlbModel>` → React remountet die Komponente bei jeder neuen URL → frischer `useRef` → neues Mesh. Alternativ: `useMemo` mit URL-Dependency statt `useRef`.
|
|
||||||
**Regel:** Wenn ein `useRef`-Initialisierungsmuster (`if (!ref.current)`) auf Prop-Änderungen reagieren muss → immer mit `key` oder `useEffect`/`useMemo` kombinieren.
|
|
||||||
|
|
||||||
### 2026-03-07 | React | staleTime zu hoch verzögert Erkennung neuer API-Daten
|
|
||||||
**Problem:** `staleTime: 30_000` auf dem gltf_geometry-Assets-Query: Nach GLB-Generierung dauerte es bis zu 30 Sekunden bis der neue `download_url` im Browser ankam — obwohl `qc.invalidateQueries()` aufgerufen wurde. Invalidierung erzwingt Refetch, aber staleTime=30s verhindert ihn falls der Cache noch "frisch" gilt.
|
|
||||||
**Lösung:** `staleTime: 0` für Queries die bei Invalidierung sofort aktuell sein müssen.
|
|
||||||
**Regel:** `staleTime: 0` für Entitäten die nach Mutations sofort aktuell sein müssen. Höhere staleTime nur für read-heavy, selten ändernde Daten (z.B. Materialliste, Templates).
|
|
||||||
|
|
||||||
### 2026-03-07 | GLB Export | Trimesh kennt keine Materialien — Blender-Pipeline ist Pflicht
|
|
||||||
**Problem:** `generate_gltf_geometry_task` nutzte trimesh für STL→GLB. Trimesh ist eine reine Geometrie-Bibliothek: keine Material-Bibliotheken, kein OCC-Kantenmarking, kein Asset-Library-Support. Das erzeugte graue, facettierte GLB-Dateien ohne Materialien.
|
|
||||||
**Lösung:** Task auf Blender headless (`export_gltf.py`) umgestellt. Übergibt: `material_map` (via `resolve_material_map()` aus `cad_part_materials`), `sharp_edges_json` (aus `mesh_attributes.sharp_edge_midpoints`), `asset_library_blend` (via `get_material_library_path()`). Trimesh nur noch als Fallback wenn Blender nicht verfügbar.
|
|
||||||
**Fehler:** Der Blender-Script (`export_gltf.py`) war schon fertig implementiert — aber `generate_gltf_geometry_task` hat ihn nie aufgerufen. Skript vorhanden ≠ Skript verdrahtet. Immer prüfen ob ein Script auch von der richtigen Stelle aufgerufen wird.
|
|
||||||
|
|
||||||
### 2026-03-07 | Frontend | `<img src>` kann keine Auth-Header senden — useAuthBlob Hook nötig
|
|
||||||
**Problem:** `<img src="/api/media/{id}/download">` schickt keine `Authorization`-Header → 401 → `imgError=true` → graues Icon in der Media Library. Betrifft alle Browser-nativen Elemente (`<img>`, `<video>`, `<audio>`).
|
|
||||||
**Lösung:** `useAuthBlob(url, enabled)` Hook: `fetch(url, { headers: { Authorization: \`Bearer ${token}\` } })` → `URL.createObjectURL(blob)` → Blob-URL als `src` nutzen. Cleanup via `URL.revokeObjectURL` + `cancelled`-Flag gegen Race Conditions.
|
|
||||||
**Für künftige Projekte:** Jeder auth-geschützte Media-Endpoint der in `<img>`/`<video>` eingebettet wird, braucht einen Blob-URL-Wrapper. Alternativ: kurzzeitige signed URLs (S3-Presigned) auf Backend-Seite.
|
|
||||||
|
|
||||||
### 2026-03-07 | Backend | publish_asset fehlte product_id + cad_file_id → kein Thumbnail-Fallback
|
|
||||||
**Problem:** `publish_asset` Celery-Task erstellte `MediaAsset`-Records ohne `product_id`/`cad_file_id` zu setzen. `get_thumbnail_url()` und `_resolve_thumbnails_bulk()` konnten keinen Thumbnail-Fallback für `still`-Assets berechnen → graue Icons für alle neu gerenderten Stills.
|
|
||||||
**Lösung:** In `publish_asset` nach dem Laden der `OrderLine` auch `Product` laden und `product_id=line.product_id` + `cad_file_id=product.cad_file_id` auf das neue `MediaAsset` setzen.
|
|
||||||
**Regel:** MediaAssets immer mit allen verfügbaren Referenz-FKs erstellen — diese werden für Thumbnail-Resolution und Tenant-Isolation benötigt.
|
|
||||||
|
|
||||||
### 2026-03-07 | Frontend | Inline 3D Viewer — GLB mit Auth via Blob URL laden
|
|
||||||
**Problem:** `useGLTF(url)` aus `@react-three/drei` kann keine Auth-Header setzen → direkte Asset-Download-URLs nicht nutzbar. `<Canvas>` braucht einen echten URL-String (keine Promise).
|
|
||||||
**Lösung:** GLB per `fetch(url, { headers: { Authorization } })` → `.blob()` → `URL.createObjectURL(blob)` → String-URL an `useGLTF(blobUrl)` übergeben. Revoke in `useEffect`-Cleanup. Polling (4s) während GLB-Generierung läuft.
|
|
||||||
**Für künftige Projekte:** Three.js / drei kennen kein Auth-Konzept. Alle auth-geschützten 3D-Assets immer als Blob-URL laden.
|
|
||||||
|
|
||||||
### 2026-03-07 | Backend | trimesh in optionalem [cad]-Extra — nicht im Docker-Build installiert
|
|
||||||
**Problem:** `trimesh` ist in `pyproject.toml` unter `[project.optional-dependencies] cad = [...]` definiert. `Dockerfile` installierte nur `pip install -e ".[dev]"` → `trimesh` fehlte → `export_gltf_colored` warf `ModuleNotFoundError` beim ersten Aufruf.
|
|
||||||
**Lösung:** `Dockerfile` auf `pip install -e ".[dev,cad]"` umgestellt + Backend-Container neu gebaut.
|
|
||||||
**Regel:** Beim Hinzufügen optionaler Extras zu `pyproject.toml` immer prüfen ob alle relevanten Container-Images das Extra auch installieren. Im Zweifel alle Runtime-Deps in `[project.dependencies]` (nicht optional) packen.
|
|
||||||
|
|
||||||
### 2026-03-07 | Frontend | URL.revokeObjectURL sofort nach click() → Race Condition
|
|
||||||
**Problem:** `URL.revokeObjectURL(url)` wurde synchron nach `a.click()` aufgerufen. `click()` für Downloads ist in manchen Browsern asynchron — die Object-URL wird freigegeben bevor der Browser-Download starten kann → leere/korrupte Datei.
|
|
||||||
**Lösung:** `setTimeout(() => URL.revokeObjectURL(url), 100)` — gibt dem Browser 100ms Zeit den Download zu registrieren, bevor die In-Memory-URL freigegeben wird.
|
|
||||||
**Gilt für:** Alle programmatischen Blob-Downloads via `createObjectURL` + `a.click()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2026-03-07 | Media Import | Falsche asset_type-Klassifizierung durch Dateinamen-Matching
|
|
||||||
**Problem:** `import_existing_media_assets` klassifizierte Dateien als `turntable` weil der Dateiname "Turntable" enthielt — unabhängig von der tatsächlichen Dateiendung. Poster-Frame-Bilder (`F-802007_Turntable_Video_White.jpg`) wurden als `asset_type=turntable` gespeichert. In der Media Browser UI wurde versucht, diese `.jpg`-Dateien als `<video>` zu rendern → kaputtes Video-Element. ZIP-Download lieferte `.jpg` statt `.mp4`.
|
|
||||||
**Lösung:**
|
|
||||||
1. **Daten-Fix**: `UPDATE media_assets SET asset_type='still' WHERE asset_type='turntable' AND (storage_key LIKE '%.jpg' OR mime_type LIKE 'image/%')` — 6 Assets reklassifiziert.
|
|
||||||
2. **Code-Fix**: `isVideoAsset()` und `isImageAsset()` nutzen jetzt zusätzlich `mime_type` zur Entscheidung. Turntable + `image/jpeg` MIME → als Bild rendern, nicht als Video.
|
|
||||||
**Regel:** Asset-Typ-Klassifizierung IMMER aus `mime_type` + Dateiendung ableiten, nie nur aus Dateiname. MIME-Type ist die verlässlichste Quelle.
|
|
||||||
|
|||||||
@@ -256,6 +256,73 @@ def _mark_sharp_and_seams(obj, smooth_angle_deg: float, sharp_edge_midpoints=Non
|
|||||||
bpy.ops.object.mode_set(mode='OBJECT')
|
bpy.ops.object.mode_set(mode='OBJECT')
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sharp_edges_from_occ(parts, sharp_edge_pairs):
|
||||||
|
"""Mark edges sharp using OCC-derived vertex-pair data.
|
||||||
|
|
||||||
|
`sharp_edge_pairs` is a list of [[x0,y0,z0],[x1,y1,z1]] in mm.
|
||||||
|
Blender mesh coordinates are in metres (STEP mm * 0.001 scale applied).
|
||||||
|
We match each OCC vertex pair against bmesh vertex positions with a 0.5 mm
|
||||||
|
tolerance (0.0005 m) and mark the matched edge as sharp.
|
||||||
|
"""
|
||||||
|
if not sharp_edge_pairs:
|
||||||
|
return
|
||||||
|
|
||||||
|
import bmesh
|
||||||
|
import mathutils
|
||||||
|
|
||||||
|
SCALE = 0.001 # mm → m
|
||||||
|
TOL = 0.0005 # 0.5 mm in metres
|
||||||
|
|
||||||
|
# OCC STEP space (Z-up, mm) → Blender (Z-up, m):
|
||||||
|
# RWGltf applies Z→Y-up, Blender import applies Y→Z-up.
|
||||||
|
# Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
|
||||||
|
occ_pairs = []
|
||||||
|
for pair in sharp_edge_pairs:
|
||||||
|
v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
|
||||||
|
v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
|
||||||
|
occ_pairs.append((v0, v1))
|
||||||
|
|
||||||
|
marked_total = 0
|
||||||
|
for obj in parts:
|
||||||
|
bm = bmesh.new()
|
||||||
|
bm.from_mesh(obj.data)
|
||||||
|
bm.verts.ensure_lookup_table()
|
||||||
|
bm.edges.ensure_lookup_table()
|
||||||
|
|
||||||
|
# Build KD-tree on vertices in WORLD space — OCC pairs are world coords,
|
||||||
|
# but mesh vertices are in local space (assembly node transform in GLB).
|
||||||
|
world_mat = obj.matrix_world
|
||||||
|
kd = mathutils.kdtree.KDTree(len(bm.verts))
|
||||||
|
for v in bm.verts:
|
||||||
|
kd.insert(world_mat @ v.co, v.index)
|
||||||
|
kd.balance()
|
||||||
|
|
||||||
|
marked = 0
|
||||||
|
for v0_occ, v1_occ in occ_pairs:
|
||||||
|
# Find closest Blender vertex to each OCC endpoint
|
||||||
|
_co0, idx0, dist0 = kd.find(v0_occ)
|
||||||
|
_co1, idx1, dist1 = kd.find(v1_occ)
|
||||||
|
if dist0 > TOL or dist1 > TOL:
|
||||||
|
continue
|
||||||
|
if idx0 == idx1:
|
||||||
|
continue # degenerate — both endpoints map to same vertex
|
||||||
|
# Find the edge shared by these two vertices
|
||||||
|
bv0 = bm.verts[idx0]
|
||||||
|
bv1 = bm.verts[idx1]
|
||||||
|
edge = bm.edges.get((bv0, bv1))
|
||||||
|
if edge is None:
|
||||||
|
edge = bm.edges.get((bv1, bv0))
|
||||||
|
if edge is not None and edge.smooth:
|
||||||
|
edge.smooth = False
|
||||||
|
marked += 1
|
||||||
|
|
||||||
|
bm.to_mesh(obj.data)
|
||||||
|
bm.free()
|
||||||
|
marked_total += marked
|
||||||
|
|
||||||
|
print(f"[blender_render] OCC sharp edges applied: {marked_total} edges marked across {len(parts)} parts", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def _import_glb(glb_file):
|
def _import_glb(glb_file):
|
||||||
"""Import OCC-generated GLB into Blender.
|
"""Import OCC-generated GLB into Blender.
|
||||||
|
|
||||||
@@ -288,8 +355,14 @@ def _import_glb(glb_file):
|
|||||||
max(v.y for v in all_corners),
|
max(v.y for v in all_corners),
|
||||||
max(v.z for v in all_corners)))
|
max(v.z for v in all_corners)))
|
||||||
center = (mins + maxs) * 0.5
|
center = (mins + maxs) * 0.5
|
||||||
for p in parts:
|
# Move root objects (parentless) to centre. Adjusting a child's local
|
||||||
p.location -= center
|
# .location by a world-space vector gives wrong results when the GLB has
|
||||||
|
# Empty parent nodes (OCC assembly hierarchy). Shifting the root moves
|
||||||
|
# the entire hierarchy correctly.
|
||||||
|
all_imported = list(bpy.context.selected_objects)
|
||||||
|
root_objects = [o for o in all_imported if o.parent is None]
|
||||||
|
for obj in root_objects:
|
||||||
|
obj.location -= center
|
||||||
|
|
||||||
return parts
|
return parts
|
||||||
|
|
||||||
@@ -479,6 +552,10 @@ if use_template:
|
|||||||
# selected object in a single C call — same effect as calling per-object
|
# selected object in a single C call — same effect as calling per-object
|
||||||
# but ~100× faster (0.2s vs 16s for 175 parts).
|
# but ~100× faster (0.2s vs 16s for 175 parts).
|
||||||
_apply_smooth_batch(parts, smooth_angle)
|
_apply_smooth_batch(parts, smooth_angle)
|
||||||
|
# If OCC extracted sharp edge vertex pairs, mark them explicitly.
|
||||||
|
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
|
||||||
|
if _occ_pairs:
|
||||||
|
_apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||||
_lap("smooth_shading")
|
_lap("smooth_shading")
|
||||||
|
|
||||||
# Material assignment: library materials if available, otherwise palette
|
# Material assignment: library materials if available, otherwise palette
|
||||||
@@ -571,6 +648,9 @@ else:
|
|||||||
import time as _time
|
import time as _time
|
||||||
_t_smooth_a = _time.time()
|
_t_smooth_a = _time.time()
|
||||||
_apply_smooth_batch(parts, smooth_angle)
|
_apply_smooth_batch(parts, smooth_angle)
|
||||||
|
_occ_pairs = _mesh_attrs.get("sharp_edge_pairs") or []
|
||||||
|
if _occ_pairs:
|
||||||
|
_apply_sharp_edges_from_occ(parts, _occ_pairs)
|
||||||
for part in parts:
|
for part in parts:
|
||||||
_assign_failed_material(part)
|
_assign_failed_material(part)
|
||||||
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True)
|
print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.2f}s)", flush=True)
|
||||||
|
|||||||
@@ -34,50 +34,130 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--material_map", default="{}")
|
parser.add_argument("--material_map", default="{}")
|
||||||
parser.add_argument("--smooth_angle", type=float, default=30.0,
|
parser.add_argument("--smooth_angle", type=float, default=30.0,
|
||||||
help="Auto-smooth angle in degrees (default 30)")
|
help="Auto-smooth angle in degrees (default 30)")
|
||||||
|
parser.add_argument("--mesh_attributes", default="{}",
|
||||||
|
help="JSON dict from cad_file.mesh_attributes (sharp_edge_pairs etc.)")
|
||||||
return parser.parse_args(rest)
|
return parser.parse_args(rest)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sharp_edges_from_occ(mesh_objects: list, sharp_edge_pairs: list) -> None:
|
||||||
|
"""Mark edges sharp using OCC vertex-pair data (same approach as blender_render.py).
|
||||||
|
|
||||||
|
sharp_edge_pairs: [[x0,y0,z0],[x1,y1,z1]] in mm.
|
||||||
|
Blender mesh coords are in metres (×0.001 scale already applied by OCC export).
|
||||||
|
"""
|
||||||
|
if not sharp_edge_pairs:
|
||||||
|
return
|
||||||
|
|
||||||
|
import bmesh
|
||||||
|
import mathutils
|
||||||
|
|
||||||
|
SCALE = 0.001 # mm → m
|
||||||
|
TOL = 0.0005 # 0.5 mm tolerance in metres
|
||||||
|
|
||||||
|
# OCC STEP space (Z-up, mm) → Blender (Z-up, m):
|
||||||
|
# RWGltf applies Z→Y-up, Blender import applies Y→Z-up.
|
||||||
|
# Net: Blender(X, Y, Z) = OCC(X*0.001, -Z*0.001, Y*0.001)
|
||||||
|
occ_pairs = []
|
||||||
|
for pair in sharp_edge_pairs:
|
||||||
|
v0 = mathutils.Vector((pair[0][0] * SCALE, -pair[0][2] * SCALE, pair[0][1] * SCALE))
|
||||||
|
v1 = mathutils.Vector((pair[1][0] * SCALE, -pair[1][2] * SCALE, pair[1][1] * SCALE))
|
||||||
|
occ_pairs.append((v0, v1))
|
||||||
|
|
||||||
|
marked_total = 0
|
||||||
|
for obj in mesh_objects:
|
||||||
|
bm = bmesh.new()
|
||||||
|
bm.from_mesh(obj.data)
|
||||||
|
bm.verts.ensure_lookup_table()
|
||||||
|
bm.edges.ensure_lookup_table()
|
||||||
|
|
||||||
|
# Build KD-tree in WORLD space — OCC pairs are world coords, but mesh
|
||||||
|
# vertices are in local space (assembly node transform in GLB hierarchy).
|
||||||
|
world_mat = obj.matrix_world
|
||||||
|
kd = mathutils.kdtree.KDTree(len(bm.verts))
|
||||||
|
for v in bm.verts:
|
||||||
|
kd.insert(world_mat @ v.co, v.index)
|
||||||
|
kd.balance()
|
||||||
|
|
||||||
|
marked = 0
|
||||||
|
for v0_occ, v1_occ in occ_pairs:
|
||||||
|
_co0, idx0, dist0 = kd.find(v0_occ)
|
||||||
|
_co1, idx1, dist1 = kd.find(v1_occ)
|
||||||
|
if dist0 > TOL or dist1 > TOL:
|
||||||
|
continue
|
||||||
|
if idx0 == idx1:
|
||||||
|
continue # degenerate — both endpoints map to same vertex
|
||||||
|
bv0, bv1 = bm.verts[idx0], bm.verts[idx1]
|
||||||
|
edge = bm.edges.get((bv0, bv1)) or bm.edges.get((bv1, bv0))
|
||||||
|
if edge is not None and edge.smooth:
|
||||||
|
edge.smooth = False
|
||||||
|
marked += 1
|
||||||
|
|
||||||
|
bm.to_mesh(obj.data)
|
||||||
|
bm.free()
|
||||||
|
marked_total += marked
|
||||||
|
|
||||||
|
print(f"OCC sharp edges applied: {marked_total} edges marked across {len(mesh_objects)} objects")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
material_map: dict = json.loads(args.material_map)
|
material_map: dict = json.loads(args.material_map)
|
||||||
|
mesh_attributes: dict = json.loads(args.mesh_attributes)
|
||||||
|
|
||||||
import bpy # type: ignore[import]
|
import bpy # type: ignore[import]
|
||||||
import math as _math
|
import math as _math
|
||||||
|
import re as _re
|
||||||
|
|
||||||
# Clean scene
|
# Clean scene
|
||||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||||
|
|
||||||
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
|
# Import geometry GLB from export_step_to_gltf.py (already in metres, Y-up)
|
||||||
bpy.ops.import_scene.gltf(filepath=args.glb_path)
|
bpy.ops.import_scene.gltf(filepath=args.glb_path)
|
||||||
print(f"Imported geometry GLB: {args.glb_path} "
|
mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||||
f"({len([o for o in bpy.data.objects if o.type == 'MESH'])} mesh objects)")
|
print(f"Imported geometry GLB: {args.glb_path} ({len(mesh_objects)} mesh objects)")
|
||||||
|
|
||||||
# Apply smooth shading using the configured angle threshold
|
# Apply smooth shading using the configured angle threshold
|
||||||
smooth_rad = _math.radians(args.smooth_angle)
|
smooth_rad = _math.radians(args.smooth_angle)
|
||||||
print(f"Applying smooth shading at {args.smooth_angle}° ({smooth_rad:.3f} rad)")
|
print(f"Applying smooth shading at {args.smooth_angle}° ({smooth_rad:.3f} rad)")
|
||||||
for obj in bpy.data.objects:
|
for obj in mesh_objects:
|
||||||
if obj.type == "MESH":
|
|
||||||
bpy.context.view_layer.objects.active = obj
|
bpy.context.view_layer.objects.active = obj
|
||||||
obj.select_set(True)
|
obj.select_set(True)
|
||||||
try:
|
try:
|
||||||
bpy.ops.object.shade_smooth_by_angle(angle=smooth_rad)
|
bpy.ops.object.shade_smooth_by_angle(angle=smooth_rad)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback for older Blender API
|
|
||||||
bpy.ops.object.shade_smooth()
|
bpy.ops.object.shade_smooth()
|
||||||
if obj.data.use_auto_smooth is not None:
|
if hasattr(obj.data, 'use_auto_smooth'):
|
||||||
obj.data.use_auto_smooth = True
|
obj.data.use_auto_smooth = True
|
||||||
obj.data.auto_smooth_angle = smooth_rad
|
obj.data.auto_smooth_angle = smooth_rad
|
||||||
|
|
||||||
|
# Apply OCC sharp edges if available (overrides pure dihedral-angle shading)
|
||||||
|
sharp_pairs = mesh_attributes.get("sharp_edge_pairs") or []
|
||||||
|
if sharp_pairs:
|
||||||
|
_apply_sharp_edges_from_occ(mesh_objects, sharp_pairs)
|
||||||
|
|
||||||
# Apply asset library materials if provided.
|
# Apply asset library materials if provided.
|
||||||
# link=False (append) is required: the GLTF exporter can only traverse
|
# link=False (append) is required: the GLTF exporter can only traverse
|
||||||
# local (appended) Principled BSDF node trees to extract PBR values.
|
# local (appended) Principled BSDF node trees to extract PBR values.
|
||||||
#
|
#
|
||||||
# IMPORTANT: OCC-exported GLBs name materials generically (mat_0, mat_1, …)
|
# Matching strategy (mirrors blender_render.py):
|
||||||
# but preserve STEP part names as mesh OBJECT names. We therefore match by
|
# Build mat_map_lower with BOTH the original key AND the _AF-stripped key,
|
||||||
# obj.name, not by slot.material.name (which is how blender_render.py works).
|
# so keys like "RingOuter_AF0" match object names "RingOuter" and vice-versa.
|
||||||
|
# Object names from RWGltf_CafWriter preserve the original STEP part name
|
||||||
|
# (including any _AF suffixes), so we strip from both sides.
|
||||||
if args.asset_library_blend and material_map:
|
if args.asset_library_blend and material_map:
|
||||||
import re as _re
|
mat_map_lower: dict = {}
|
||||||
mat_map_lower = {k.lower().strip(): v for k, v in material_map.items()}
|
for k, v in material_map.items():
|
||||||
|
kl = k.lower().strip()
|
||||||
|
mat_map_lower[kl] = v
|
||||||
|
# Also add the _AF-stripped version so either form matches
|
||||||
|
stripped = kl
|
||||||
|
prev = None
|
||||||
|
while prev != stripped:
|
||||||
|
prev = stripped
|
||||||
|
stripped = _re.sub(r'_af\d+$', '', stripped)
|
||||||
|
if stripped != kl:
|
||||||
|
mat_map_lower.setdefault(stripped, v)
|
||||||
|
|
||||||
needed = set(mat_map_lower.values())
|
needed = set(mat_map_lower.values())
|
||||||
|
|
||||||
# Append materials from library (link=False so glTF exporter can read nodes)
|
# Append materials from library (link=False so glTF exporter can read nodes)
|
||||||
@@ -101,38 +181,60 @@ def main() -> None:
|
|||||||
|
|
||||||
if appended:
|
if appended:
|
||||||
assigned = 0
|
assigned = 0
|
||||||
mesh_objects = [o for o in bpy.data.objects if o.type == "MESH"]
|
assigned_names: set = set()
|
||||||
for obj in mesh_objects:
|
for obj in mesh_objects:
|
||||||
# Strip Blender's .001/.002 deduplication suffix
|
# Strip Blender's .001/.002 deduplication suffix
|
||||||
base_name = _re.sub(r'\.\d{3}$', '', obj.name)
|
base_name = _re.sub(r'\.\d{3}$', '', obj.name)
|
||||||
# Strip OCC assembly-instance suffix (_AF0, _AF1, … added by
|
# Also strip _AF suffix from object name so both directions match
|
||||||
# RWGltf_CafWriter when the same part appears multiple times).
|
|
||||||
# Apply repeatedly in case of nested suffixes (_AF0_AF1, etc.)
|
|
||||||
prev = None
|
prev = None
|
||||||
while prev != base_name:
|
while prev != base_name:
|
||||||
prev = base_name
|
prev = base_name
|
||||||
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
|
base_name = _re.sub(r'_AF\d+$', '', base_name, flags=_re.IGNORECASE)
|
||||||
|
|
||||||
mat_name = mat_map_lower.get(base_name.lower().strip())
|
|
||||||
|
|
||||||
# Prefix fallback: some sub-assembly nodes have names that
|
|
||||||
# extend a known key (e.g. key="Ring" matches "Ring_inner_AF0").
|
|
||||||
# Sort by key length descending so the most-specific key wins.
|
|
||||||
if not mat_name:
|
|
||||||
lower_base = base_name.lower().strip()
|
lower_base = base_name.lower().strip()
|
||||||
|
mat_name = mat_map_lower.get(lower_base)
|
||||||
|
|
||||||
|
# Prefix fallback for sub-assembly nodes
|
||||||
|
if not mat_name:
|
||||||
for key, val in sorted(mat_map_lower.items(), key=lambda x: len(x[0]), reverse=True):
|
for key, val in sorted(mat_map_lower.items(), key=lambda x: len(x[0]), reverse=True):
|
||||||
if len(key) >= 5 and len(lower_base) >= 5 and (
|
if len(key) >= 3 and len(lower_base) >= 3 and (
|
||||||
lower_base.startswith(key) or key.startswith(lower_base)
|
lower_base.startswith(key) or key.startswith(lower_base)
|
||||||
):
|
):
|
||||||
mat_name = val
|
mat_name = val
|
||||||
break
|
break
|
||||||
|
|
||||||
if mat_name and mat_name in appended:
|
if mat_name and mat_name in appended:
|
||||||
|
# Make mesh data single-user before modifying material slots;
|
||||||
|
# otherwise clearing materials on a shared data block removes
|
||||||
|
# slots from ALL objects that share it.
|
||||||
|
if obj.data.users > 1:
|
||||||
|
obj.data = obj.data.copy()
|
||||||
obj.data.materials.clear()
|
obj.data.materials.clear()
|
||||||
obj.data.materials.append(appended[mat_name])
|
obj.data.materials.append(appended[mat_name])
|
||||||
assigned += 1
|
assigned += 1
|
||||||
|
assigned_names.add(obj.name)
|
||||||
|
else:
|
||||||
|
pass # name-matching miss — may be covered by single-material fallback below
|
||||||
print(f"Material substitution: {assigned}/{len(mesh_objects)} mesh objects assigned")
|
print(f"Material substitution: {assigned}/{len(mesh_objects)} mesh objects assigned")
|
||||||
|
|
||||||
|
# Single-material fallback: if only one library material was loaded,
|
||||||
|
# apply it to every object that name-matching missed.
|
||||||
|
# (mat_map_lower may contain unresolvable pass-through values like
|
||||||
|
# "Stahl; Durotect CMT", so checking appended is more reliable.)
|
||||||
|
if len(appended) == 1:
|
||||||
|
default_mat_name, default_mat = next(iter(appended.items()))
|
||||||
|
if default_mat:
|
||||||
|
fallback = 0
|
||||||
|
for obj in mesh_objects:
|
||||||
|
if obj.name not in assigned_names:
|
||||||
|
if obj.data.users > 1:
|
||||||
|
obj.data = obj.data.copy()
|
||||||
|
obj.data.materials.clear()
|
||||||
|
obj.data.materials.append(default_mat)
|
||||||
|
fallback += 1
|
||||||
|
if fallback:
|
||||||
|
print(f"Single-material fallback: applied '{default_mat_name}' to {fallback} unmatched objects")
|
||||||
|
|
||||||
# Export production GLB with full PBR material data
|
# Export production GLB with full PBR material data
|
||||||
try:
|
try:
|
||||||
bpy.ops.export_scene.gltf(
|
bpy.ops.export_scene.gltf(
|
||||||
|
|||||||
Reference in New Issue
Block a user