Dokumentiert drei neue Learnings aus der GE360-HF Wälzkörper-Positions-Untersuchung: 1. BRepMesh auf Compound: Triangulation in Definition-Space, Face-loc = Instance-Placement 2. IsSame() vs IsPartner() für Assembly-Instanz-Deduplizierung 3. Stale GLB-Cache maskiert Code-Fixes — nach Tessellierungs-Änderungen Cache invalidieren Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
43 KiB
Projekt-Learnings — Schaeffler Automat
Format
Datum | Kategorie | Problem → Lösung
Learnings
2026-01-15 | Architektur | Backend-Port-Konflikt
Port 8000 war belegt → Port 8888 in docker-compose.yml + Vite-Proxy. Früh festlegen und in CLAUDE.md dokumentieren.
2026-01-20 | Datenbank | SQLAlchemy trackt key-value-Store-Mutations nicht
Admin-Settings via ORM gespeichert → Änderungen nicht persistiert. SQLAlchemy erkennt keine Mutation an geladenen Value-Feldern.
Lösung: Direktes session.execute(update(...)) statt ORM-Mutation. Key-Value-Stores immer mit direktem SQL verwalten.
2026-01-25 | Render-Pipeline | Blender ignoriert STEP-Einheiten (mm vs. m)
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.
2026-01-28 | Render-Pipeline | Blender 5.0 hat scene.node_tree entfernt
_setup_bg_compositor() → Python-Exception → Blender exit code 0 → Task fälschlicherweise "completed".
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.
2026-02-05 | Material-System | Material-Alias-Lookup-Reihenfolge falsch
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: material_service.py: Aliases zuerst, dann exakter Name, dann Pass-through.
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. Lösung: In Template-Mode Lights, World und Color-Management-Override vollständig überspringen; nur Kamera ggf. neu berechnen.
2026-02-15 | Celery | Blender-Queue-Flooding durch falsche Concurrency
Alle Tasks auf step_processing (concurrency=8) → 8 Workers gleichzeitig an blender-renderer (max 1) → 7× Timeout.
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.
2026-02-18 | Frontend | Tailwind CSS-Variablen inkompatibel mit opacity-Syntax
bg-surface/50 erzeugt rgb(var(--color-bg-surface) / 0.5) — invalides CSS wenn Variable ein Hex-Wert ist.
Lösung: style={{ backgroundColor: 'var(--color-bg-surface)' }}. Alternativ: CSS-Vars im RGB-Channel-Format definieren (255 255 255).
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.
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.
2026-02-20 | STL-Cache | blender-renderer fehlte /convert-stl Endpoint
Blender renderte + konvertierte in einem Schritt, persistierte STL nicht.
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
"Stahl v2" in DB nicht in materials noch in material_aliases → keine Ersetzung, Silent-Fail.
Lösung: Alias direkt in DB eintragen. Bei Render ohne Materialersetzung immer zuerst resolve_material_map() debuggen, Alias-Tabelle prüfen.
2026-02-25 | Frontend | canDispatch-Bedingung zu restriktiv
canDispatch enthielt && hasRetryable → Button fehlte wenn alle Zeilen initial "pending".
Lösung: hasRetryable entfernt; Validierung im Backend. Aktions-Buttons nicht zu stark von abgeleiteten Zuständen abhängig machen.
2026-02-28 | Frontend | MaterialInput-Dropdown ohne Hintergrund
bg-surface mit CSS-Hex-Variable → transparenter Dropdown (siehe 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.
2026-03-06 | Refactor | .gitignore core trifft Verzeichnisse
core in .gitignore ignorierte backend/app/core/ Verzeichnis.
Lösung: /core (root-relativ) — trifft nur Datei im Root, nicht verschachtelte Verzeichnisse.
2026-03-06 | Architektur | Blender-HTTP-Service vs. direkter Subprocess
Flask-HTTP-Microservice = Single-Point-of-Failure, kein Scaling, HTTP-Overhead.
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.
2026-03-06 | Refactor | Bash CWD-Problem durch Hook-Pfad-Auflösung
Nach cd frontend im Bash-Tool blieb CWD in frontend/ → Hook-Pfad nicht gefunden → alle Tool-Calls blockiert.
Lösung: Symlink frontend/.claude → .claude. Hooks mit absoluten Pfaden konfigurieren; cd nur mit && am Ende der Command-Chain.
2026-03-06 | Multi-Tenancy | PostgreSQL RLS mit current_setting und Null-Safety
current_setting('app.current_tenant_id') wirft Exception wenn 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'.
2026-03-11 | Tessellation | GMSH CharacteristicLength ≠ OCC linear_deflection
OCC linear_deflection ist ein Oberflächenabweichungs-Toleranzwert (max. Abstand Mesh→echte Fläche). GMSH CharacteristicLengthMax ist eine Kantenlängenvorgabe. Gleicher Wert (0.1) erzeugt bei GMSH 50× mehr Dreiecke → 231MB statt 3MB.
Lösung: CharacteristicLengthMax = linear_deflection * 15.0 (15× Faktor). MinimumCirclePoints = min(20, ceil(2π/angular_deflection)) — ohne Cap liefert angular_deflection=0.1rad → 63 Punkte/Kreis (10× zu dicht). Mit Cap 20: ~20MB statt 231MB, OCC-ähnliche Dichte bei 0 Fan-Dreiecken.
2026-03-11 | Tessellation | BRep_Builder.UpdateFace — richtige Signatur
OCP Python API: BRep_Builder.UpdateFace(face, triangulation) — 2-Argumente-Form, NICHT (face, tri, loc, tolerance) wie in C++-Doku. Falsche Signatur führt zu Silent-Exception, alle Faces fallen auf BRepMesh zurück.
2026-03-11 | Tessellation | GMSH OOM bei Assembly-Compound
GMSH verarbeitet ganzen Compound auf einmal → 25-teilige Lager-Baugruppe: 2.3GB RAM → OOM-Kill (exit -9).
Lösung: Per-Solid-Iteration via TopExp_Explorer(root_shape, TopAbs_SOLID). BRep_Builder.UpdateFace aktualisiert Face-Objekte in-place; Parent-Compound sieht Updates automatisch.
2026-03-11 | Celery | Timeout in Worker-Code ≠ Running Worker liest neue Version
export_glb.py mit 600s-Timeout in Container-Datei — aber Celery-Worker hatte Code beim Start geladen. Fehler zeigt timeout=120 obwohl Datei 600 enthält.
Lösung: docker compose restart render-worker nach Datei-Update. Celery lädt Module beim Start, nicht bei Task-Ausführung.
2026-03-06 | Refactor | Domain-Driven Migration: Compat-Shims statt Big-Bang
Vollständige Migration in einem Schritt bricht alle Imports.
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.
2026-03-06 | Workflow-System | Celery Canvas vs. Custom Workflow-Engine
Custom Graph-Traversal-Engine = 2-3 Wochen Eigenentwicklung.
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.
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.
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.
2026-03-06 | Multi-Tenancy | audit_log.tenant_id NOT NULL blockiert alle Notifications
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: ALTER TABLE audit_log ALTER COLUMN tenant_id DROP NOT NULL. Broadcast-Notifications (system-weit) dürfen NULL tenant_id haben.
2026-03-06 | Frontend | GET /api/tenants gibt 307 Redirect zurück
FastAPI registriert /tenants/ (trailing slash) → GET /tenants → 307. Axios folgt, verliert Authorization-Header → 401.
Lösung: getTenants() auf /tenants/ (mit trailing slash). Generell: FastAPI 307-Redirects verlieren Auth-Header in Axios. Immer trailing slash im Frontend verwenden.
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.
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.
2026-03-06 | Docker | docker compose in Container braucht multi-stage CLI-Copy
python:3.11-slim hat kein docker binary.
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.
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
GLTF-Materialien sind shared objects — direkte Mutation von child.material.wireframe beeinflusst alle Instanzen.
Lösung: child.material = child.material.clone() vor Wireframe-Mutation. GLTF-Materialien bei Runtime-Modifikationen immer zuerst clonen.
2026-03-06 | pytest | Backend ohne dev-Dependencies
pip install -e . ohne [dev] → kein pytest → ModuleNotFoundError.
Lösung: pip install -e ".[dev]". Dev-Extras immer im Dockerfile angeben 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()
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:
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:
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/undbackend/app/domains/*/models.pydirekt lesen - API-Endpoints:
backend/app/api/routers/undbackend/app/domains/*/routers.pylesen — kein curl/docker exec nötig - Celery-Tasks:
backend/app/tasks/step_tasks.pyundbackend/app/domains/pipeline/tasks/lesen - Render-Scripts:
render-worker/scripts/lesen —blender_render.py,export_gltf.pyetc. - Frontend-API:
frontend/src/api/*.tszeigt alle API-Calls und Typen - Gespeicherte Daten:
CadFile.mesh_attributes,Product.cad_part_materials,SystemSettingin 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 -cfür Code-Exploration — dauert lang und braucht laufende Container - MEMORY.md enthält Container-Capabilities: OCP nur in
render-worker, nicht inworkeroderbackend
2026-03-11 | Render-Pipeline | Production GLB: edit-mode mark_sharp+mark_seam für korrekte GLB Sharp Edges
In Blender 5.0: shade_smooth_by_angle() allein reicht nicht für scharfe Kanten im GLB-Export.
Lösung: Edit-mode-Operators verwenden: edges_select_sharp(sharpness=angle) → mark_sharp() → mark_seam().
Der glTF-Exporter erstellt an den als sharp markierten Edges Vertex-Splits (duplizierte Vertices mit verschiedenen Normalen).
Verifizierung: 543 sharp+seam Edges markiert → production GLB hat 812 extra Vertices + 6027 Positionen mit mehreren Normalen = scharfe Kanten korrekt enkodiert.
Wichtig: calc_normals_split() wurde in Blender 5.0 entfernt (→ AttributeError). Nicht mehr nötig: export_apply=True triggert Vertex-Splitting automatisch.
2026-03-11 | Render-Pipeline | MediaAsset DELETE+INSERT erzeugt neue UUID → 404 auf gecachte URLs
generate_gltf_production_task und generate_gltf_geometry_task löschten alten MediaAsset-Record und erstellten neuen mit neuer UUID.
Gecachte Download-URLs (z.B. im Frontend-State) zeigten auf die alte, gelöschte UUID → 404.
Lösung: UPSERT: existierenden Record aktualisieren (existing.storage_key = _key) statt DELETE+INSERT. UUID bleibt stabil.
2026-03-11 | Render-Pipeline | Production GLB: OCC custom_normal überschreibt Blender-Normalen
export_step_to_gltf.py (RWGltf_CafWriter) embedded per-corner normals from OCC tessellation as a custom_normal attribute (CORNER, INT16_2D) in the geometry GLB. Blender's glTF importer preserves this as a custom attribute. The glTF exporter then re-exports these pre-baked normals unchanged, ignoring shade_smooth_by_angle processing and explicit edge.smooth=False sharp marks — sharp edges are invisible in the production GLB.
Lösung: In export_gltf.py und blender_render.py: nach GLB-Import das custom_normal-Attribut von allen Mesh-Objekten entfernen, BEVOR shade_smooth_by_angle() aufgerufen wird. Dann berechnet Blender die Normalen neu aus den sharp-edge-Marks.
Diagnose: File-Size-Test: WITH custom_normal=1218960 Bytes, WITHOUT=1137944 Bytes. Re-import zeigte has_custom_normals=True. GLB-JSON-Inspektion mit struct.unpack + json.loads direkt auf .glb-Datei.
for obj in mesh_objects:
if "custom_normal" in obj.data.attributes:
obj.data.attributes.remove(obj.data.attributes["custom_normal"])
2026-03-11 | Render-Pipeline | OCC B-rep sharp edges: BRep_Tool.Polygon3D_s() gibt None zurück
BRep_Tool.Polygon3D_s(edge, loc) und PolygonOnTriangulation_s() geben in XCAF-Compound-Kontext immer None zurück — Tessellation-Polygon-Daten liegen auf Component-Instanzen, nicht auf den Compound-Edges. Ergebnis: 612 scharfe Kanten erkannt, 0 Segment-Paare extrahiert.
Lösung: GCPnts_UniformAbscissa(curve3d, step_mm=0.3, tol=1e-6) auf der analytischen B-rep-Kurve (BRepAdaptor_Curve) samplen. 0.3mm-Schritt garantiert dass konsekutive Sample-Paare die Tessellations-Kanten (~0.78-1.55mm) straddeln — die KD-Tree-Suche (TOL=0.5mm) findet dann die richtigen Blender-Mesh-Edges. Ergebnis: 17.129 Segment-Paare, 1.364 Kanten in Blender markiert.
Imports: from OCP.GCPnts import GCPnts_UniformAbscissa; from OCP.BRepAdaptor import BRepAdaptor_Curve
2026-03-12 | OCC/USD | export_step_to_usd.py: face_loc == shape_loc → Doppel-Transform → falsche Mesh-Positionen
BRepMesh_IncrementalMesh auf dem Root-Compound tesselliert alle Instanzen. BRep_Tool.Triangulation_s(face, face_loc) liefert dabei einen face_loc, der exakt die Instance-Platzierung des shape kodiert (identisch zu shape.Location()). In _extract_mesh() wurden beide Transforms nacheinander angewendet: erst face_loc.Transformation(), dann shape_trsf — was eine Doppel-Rotation ergibt. Für einen Zylinder-Rollensatz (12 Rollen à 30° Abstand) führt das dazu, dass mehrere Rollen auf dieselbe falsche Position kollabieren (z.B. -75° × 2 = -150° = +105° × 2 mod 360° → Rollen 3 und 9 landen identisch).
Lösung: if face_has_loc: ... elif shape_has_loc: ... statt if face_has_loc: ... if shape_has_loc: .... shape_loc ist nur ein Fallback für direkt tessellierte Shapes (nicht als Teil eines Compounds), bei denen face_loc identity ist.
Beweis: Mit face_only-Extraktion (nur face_loc, kein shape_loc) erscheinen alle 10 Rollen bei genau 223,9mm Radius, gleichmäßig 30° verteilt.
Offene Fragen
- Azure AI Credentials für Phase 4 (Bildvalidierung) noch nicht konfiguriert
- pythonOCC verfügbar im render-worker (via cadquery dependency)? Deployment-Test ausstehend
- @xyflow/react noch nicht installiert — npm install nötig nach nächstem
docker compose up --build frontend - Material-Alias-Seeding deckt noch nicht alle deutschen Materialbezeichnungs-Varianten ab
- Turntable-Animation: bg_color via FFmpeg-Overlay — Qualität bei Transparenz-Edges prüfen
2026-03-11 | OCP/Python | id(solid.TShape()) ist nicht stabil
In OCP (pybind11-basiert) gibt jeder Aufruf von solid.TShape() ein neues Python-Wrapper-Objekt zurück, das dieselbe C++ TShape-Instanz wrapet. id() gibt daher jedes Mal einen anderen Wert → Deduplizierung per id() schlägt immer fehl. Lösung: solid.IsSame(other_solid) verwenden (vergleicht TShape-Zeiger intern, liefert True für gleiche TShape mit unterschiedlicher Location/Orientation).
2026-03-11 | GMSH | TopExp_Explorer(SOLID) übersieht freie Faces und Shells
TopExp_Explorer(root, SOLID) findet nur TopoDS_Solid-Shapes, nicht freie TopoDS_Shell- oder TopoDS_Face-Shapes auf Compound-Ebene. OCC's BRepMesh_IncrementalMesh auf dem Root-Compound tesselliert alle Typen rekursiv. Lösung: BRepMesh-Baseline zuerst auf den vollen Root-Compound, dann GMSH als Override nur für SOLID-Shapes. So werden keine Shapes übersprungen.
2026-03-11 | GMSH | CharacteristicLengthMax vs. OCC linear_deflection
OCC linear_deflection=0.1mm auf einem 50mm-Zylinder → Kantenlänge ~5mm. GMSH CharacteristicLengthMax=0.1×15=1.5mm → 3× mehr Unterteilungen → 9× mehr Dreiecke → GLB 7× größer. Lösung: CharacteristicLengthMax = linear_deflection × 50 (≈5mm), MinimumCirclePoints = min(12, ...) statt min(20). Ergebnis: GMSH 91% von OCC-Größe (Ziel ≤120% ✓).
2026-03-12 | GMSH | Priority 3 vollständig — GMSH-Pipeline Status
GMSH 4.15.1 in render-worker installiert. tessellation_engine=gmsh ist der aktive DB-Default. _tessellate_with_gmsh() in export_step_to_gltf.py vollständig: CharacteristicLengthMax = linear_deflection × 50, MinimumCirclePoints = min(12, ...), REVERSED Solids bleiben erhalten (kein invertierter Jacobian). Produktion-GLB nutzt Cache-Reuse (kein Re-Tessellieren bei Materialwechsel). Sharp-Edge-Extraktion läuft nach Tessellierung unabhängig vom Engine-Typ — Injected N segment pairs into GLB extras gilt für beide Pfade.
2026-03-12 | OCC | BRepMesh auf Compound: Triangulation in Definition-Space, Face-loc = Instance-Placement
BRepMesh_IncrementalMesh(compound) tesselliert alle Faces in Definition-Space-Koordinaten. Für Instanzen mit Placement enthält face.Location() (= TopoDS_Shape-Location) die Instance-Transformation. BRep_Tool.Triangulation_s(face, loc) gibt die Triangulation-Knoten in Definition-Space zurück, loc enthält die Face-Location (= Instance-Placement). BRep_Builder.UpdateFace(face_def, tri) mit einer aus solid.Located(TopLoc_Location()) gewonnenen Face schreibt auf das geteilte TShape — ALLE Instanzen der gleichen Geometrie sehen die neue Triangulation, da sie IsPartner teilen.
2026-03-12 | OCC/XCAF | IsSame() vs IsPartner() — Deduplizierung bei Assembly-Instanzen
IsSame() prüft TShape-Pointer UND Location → für 16 Instanzen desselben Wälzkörpers sind alle 16 "unique" (unterschiedliche Location). IsPartner() prüft nur TShape-Pointer → gibt 9 tatsächlich unterschiedliche Geometrien. In export_step_to_gltf.py GMSH-Schleife: IsSame()-Deduplizierung tesselliert alle 16 Instanzen separat, aber da sie das gleiche TShape teilen, werden alle 16 mal auf dasselbe TShape geschrieben (idempotent, korrekt). RWGltf_CafWriter traversiert XCAF-Labelhierarchie und liest Triangulation von Definition-Labels (Identity-Location) — kein Double-Transform.
2026-03-12 | Debugging | Stale GLB-Cache maskiert Code-Fixes
Bug "Wälzkörper an falscher Position" war in Code durch commit 638b93b (IsSame-Fix) bereits behoben. Aber gecachtes Produktions-GLB (vor dem Fix generiert) zeigte weiterhin falsche Positionen im Viewer. Lösung: Geometry-GLB manuell neu generieren oder step_file_hash = NULL in DB um Cache-Invalidierung zu erzwingen. Nach Code-Fixes an Tessellierung/Export IMMER alle betroffenen GLB-Caches invalidieren.