Skip to main content
Glama
README.md
# EnfusionTexKit

**Headless PBR-Texture-Adapter für Arma Reforger / Enfusion — von einer Quelltextur zum einsatzbereiten `.emat` im Addon, per MCP oder CLI.**

TexKit portiert die Map-Erzeugungs-Algorithmen von [Materialize](https://github.com/BoundingBoxSoftware/Materialize) (GPL-3.0) in einen schlanken Python-Kern und ergänzt genau das, was Materialize für Enfusion prinzipbedingt nicht liefern kann: das korrekte Enfusion-Kanal-Packing, die `.emat`-Generierung und die atomare Ablage im Ziel-Addon.

---

## Warum das Ganze

Materialize erzeugt gute PBR-Einzelmaps, aber sein Output passt nicht auf Enfusion:

- Die Property-Map packt nur R/G/B — Enfusion braucht **Occlusion in NMO.a** und **Roughness in BCR.a**.
- Materialize liefert **Smoothness**, Enfusion will **Roughness** (`R = 1 − S`).
- **`.emat`-Erzeugung** und **Addon-Ablage** fehlen komplett.

TexKit schließt diese Lücke. Es ist bewusst **ein Adapter, kein Materialize-Ersatz**: kein GUI, keine Echtzeit-Vorschau, kein GPU-Pfad. Quelltextur (oder fertige Materialize-Exporte) rein → fertiges Enfusion-Material im Addon raus. Materialize bleibt für die Handarbeit (Tiling, Alignment) im Werkzeugkasten.

---

## Was es kann

- **End-to-end Map-Generierung** aus einer einzigen Diffuse-Quelltextur: Licht/Schatten-Entfernung → Height → Normal (DirectX) → Metallic/Roughness → AO → optional Edge-Wear.
- **Enfusion-Kanal-Packing** nach Vanilla-Konvention (BCR / NMO / Opacity).
- **Material-Presets als Erkennungslogik**, nicht als Flachwerte — eine Uniform bleibt matt, ihre Schnallen glänzen trotzdem (siehe [Presets](#material-presets)).
- **Alpha-Dilation gegen den schwarzen Saum** an Cutout-Texturen — automatisch in der Foliage-Pipeline und als eigenständiges `tex_dilate` (siehe [Alpha-Dilation](#alpha-dilation-schwarzer-cutout-saum)).
- **Zwei-Phasen-`.emat`-Flow**, der echte `.edds`-GUIDs aus dem Workbench-Import verdrahtet.
- **Ingest-Brücke** für bereits in Materialize erzeugte Einzelmaps (Smoothness→Roughness, Normal-Y-Konvention).
- **Kanal-Kontaktblatt** pro Lauf als visuelle Kontrolle + `tex_inspect` für Zahlen ohne Viewer-Fehldeutung.
- Läuft als **MCP-Server** (für Claude) **und** als **CLI** über demselben Kern.

### Nicht-Ziele (v1)

Kein GUI/Vorschau, kein GPU-Pfad, kein eigener BCn/EDDS-Encoder (die Workbench kompiliert PNG→`.edds`), kein Tiling-Maker, kein Perspektiv-Alignment, keine Modell-/Prefab-Zuweisung (das macht `enfusion-mcp`).

---

## Installation & Start

**Runtime:** Python ≥ 3.12, Abhängigkeiten `numpy`, `pillow`, `scipy`.

### Als MCP-Server (registriert in `claude_desktop_config.json` **und** `~/.claude.json`)

```bash
uv run --no-project --with mcp --with numpy --with pillow --with scipy server.py
```

> `--with mcp` allein würde eine isolierte Umgebung ohne den Bildstack erzeugen. Das `mcp`-Paket bleibt bewusst **nicht** global installiert.

### Als CLI

```bash
python cli.py <command> ...
```

---

## Der Zwei-Phasen-Workflow

Feldbefund aus Vanilla und Phillips Addons: **`.emat` referenziert ausschließlich `.edds`, nie PNG.** Die `.edds` (+ `.edds.meta` mit GUID) entsteht erst durch den Workbench-Import. Deshalb ist der Ablauf zwingend zweistufig:

```
┌─ Phase 1 (TexKit) ──────────────┐   ┌─ Dazwischen ────────┐   ┌─ Phase 2 (TexKit) ─────────────┐
│ tex_material / material         │   │ Workbench-Import     │   │ tex_emat / material-finalize    │
│ Quelle → Maps → Packing →       │──▶│ PNG → .edds +        │──▶│ liest GUIDs aus .edds.meta,     │
│ PNGs atomar ins Addon staged    │   │ .edds.meta (WB-GUID) │   │ schreibt fertiges .emat         │
└─────────────────────────────────┘   └─────────────────────┘   └─────────────────────────────────┘
```

Fehlen in Phase 2 die `.edds.meta`, bricht TexKit mit einer klaren Import-Aufforderung ab, statt ein kaputtes Material zu schreiben. Der Workbench-Import selbst läuft außerhalb von TexKit (headless via `enfusion-mcp` orchestrierbar).

---

## MCP-Werkzeuge

Alle Tools sind **zustandslos** — jeder Aufruf ist vollständig über seine Argumente beschrieben.

| Tool | Phase | Zweck |
|---|---|---|
| `tex_material(source, preset, addon_dir, name, sub_path?, overrides?, overwrite?)` | 1 | End-to-end: Quelle → alle Maps → Packing → atomar ins Addon staged, mit Kontaktblatt. Schreibt bewusst **noch kein** `.emat`. |
| `tex_generate(source, preset, out_dir, overrides?)` | — | Nur Maps + Kontaktblatt in einen freien Ordner (kein Addon-Schreiben). Für Experimente/Sichtprüfung. |
| `tex_emat(addon_dir, name, family, sub_path?, overwrite?)` | 2 | Schreibt das `.emat`, nachdem die Workbench die PNGs zu `.edds` importiert hat. Liest GUIDs aus `.edds.meta`. |
| `tex_pack(maps_dir, out_dir, name, normal_y?)` | — | Ingest: Materialize-Einzelmaps → BCR/NMO. Invertiert Smoothness zu Roughness. `normal_y`: `directx` (Default) / `opengl`. |
| `tex_dilate(image, out, masks?, threshold?, min_gain?, dry_run?)` | — | Alpha-Dilation gegen den schwarzen Cutout-Saum. Zieht die Farbe der deckenden Pixel per NN über die Silhouette; Alpha byte-identisch. Schreibt nur bei messbarem Gewinn. |
| `tex_params(preset?)` | — | Alle Presets mit sämtlichen Parametern + Defaults als JSON. Die Feldnamen sind zugleich die gültigen Override-Pfade. |
| `tex_inspect(image)` | — | Kanalstatistiken (min/max/mean je R/G/B/A) + Neutralitäts-Hinweise — prüft BCR/NMO/Masken ohne Viewer. |

---

## CLI

Spiegelt die MCP-Tools; Fehler → `error: ...` auf stderr + Exit 1.

```bash
# Nur Maps + Kontaktblatt in einen Ordner
python cli.py generate <source> --preset metall --out <dir> [--overrides '{"roughness.base_rough":0.5}']

# Phase 1: Maps ins Addon stagen
python cli.py material <source> --preset stoff --addon <addon_dir> --name MyMat [--sub-path Assets/TexKit] [--overwrite]

# Phase 2: .emat aus .edds.meta-GUIDs (nach Workbench-Import)
python cli.py material-finalize --addon <addon_dir> --name MyMat --family stoff [--overwrite]

# Ingest: Materialize-Einzelmaps packen
python cli.py pack <maps_dir> --out <dir> --name MyMat [--normal-y directx|opengl]

# Alpha-Dilation gegen den schwarzen Cutout-Saum
python cli.py dilate <image> --out <png> [--mask <opacity.png> ...] [--threshold 250] [--gain 8] [--dry-run]

# Presets + Parameter als JSON listen
python cli.py params
```

---

## Material-Presets

Presets sind **Erkennungslogik, keine Flachwerte.** Reale Assets mischen Materialien in einer Textur — jede Familie definiert daher neben den Basiswerten eine Detail-Erkennung (Sättigungs-/Luminanz-Heuristik), die abweichende Bereiche findet und dort Metalness/Roughness lokal umsteuert.

| Preset | Basis | Erkennung tut | Cutout |
|---|---|---|---|
| **`metall`** | metallisch, glänzend | senkt Metal/Glanz auf Dreck, Rost, Gummi-/Stoffanteilen (auch für Fahrzeuge/lackiertes Metall) | nein |
| **`stoff`** | matt, Metal 0 (`base_rough=0.92`) | gibt **nur** an Metall-Hardware und Glanzleder Glanz frei (strengere Schwellen: `sat_gain=4.0`, `lum_floor=0.35`) | nein |
| **`foliage`** | dielektrisch matt (`base_rough=0.75`) | Metal hart 0 (`detect_gain=0`, `metal_cap=0`) — Alphakanal **Pflicht**, erzeugt `_Opacity` | **ja** |

**Overrides** per flachem Punktpfad-Dict — der Pfad ist entweder ein Top-Level-Preset-Feld oder ein `sub_params.feld`:

```json
{ "roughness.base_rough": 0.5, "edge_wear": 0.2, "metallic.sat_gain": 3.0 }
```

`tex_params` listet jeden gültigen Pfad. Ein unbekannter Pfad wirft `ValueError` mit Nennung des Pfads.

---

## Kanal-Konventionen

| Datei | Packung |
|---|---|
| `<Name>_BCR` | **B**ase**C**olor (RGB) + **R**oughness in **A** |
| `<Name>_NMO` | **N**ormal.RG (DirectX, Y-down/Grün invertiert) + **M**etal in B + **O**cclusion/AO in A |
| `<Name>_Opacity` / `_Mask` | Graustufe in RGB, A = 255 (nur bei Cutout-Presets) |

> ⚠️ **Viewer deuten Daten-Alpha als Transparenz fehl.** Beurteile gepackte Maps deshalb nie am RGBA-Bild direkt, sondern über das **Kontaktblatt** (`<Name>_contact.png`, pro Lauf erzeugt) oder `tex_inspect`.

Die **Silhouetten-Maske** wird immer genutzt, wenn die Quelle echte Transparenz trägt (`alpha.min() < 1.0`, Schwelle `alpha ≥ 8/255`) — sonst prägen sich Hintergrund-Halos in Height/Normal ein. `cutout`-Presets verlangen diese Maske zusätzlich und brechen ohne Alpha mit `ValueError` ab.

---

## Alpha-Dilation (schwarzer Cutout-Saum)

Blätter, Netze und Laub mit weichem Alpha-Rand zeigen im Spiel einen dunklen Saum: die halbtransparenten Randpixel wurden beim Speichern gegen den schwarzen Hintergrund verrechnet, ihre Farbe ist dauerhaft abgedunkelt, und die GPU mischt beim Mipmapping zusätzlich mit dem schwarzen Umfeld. TexKit zieht die Farbe der voll deckenden Pixel per **Nearest-Neighbour** über die Silhouette hinaus — **die Maske/der Alphakanal bleibt byte-identisch**, nur die Farbe darunter wird korrigiert.

- **Automatisch** in der `foliage`/Cutout-Pipeline vor dem BCR-Packing — neu erzeugte Materialien haben den Saum ab Werk nicht.
- **Manuell** via `tex_dilate` / `python cli.py dilate` für bestehende oder fremde Texturen.

Drei nicht offensichtliche Regeln sind fest eingebrannt (haben im Vorgänger-Script mehrere Fehlversuche gekostet):

1. **Schwelle 250, nicht 200.** Die Schwarz-Verrechnung reicht weit in den scheinbar deckenden Bereich; erst ab Maskenwert ~251 ist die Farbe sauber. Schwelle 200 verschleppt dunkle Werte nach außen.
2. **Masken im R-Kanal lesen**, nicht in der Luminanz — manche Masken haben Daten nur auf R, `convert("L")` drückt die künstlich herunter.
3. **Vereinigungsmenge bei mehreren Masken** (`--mask` mehrfach) — wird eine Textur mit verschiedenen Masken benutzt, darf kein Pixel geändert werden, der unter einer anderen Maske sichtbar ist. Größenunterschiede werden per NEAREST aufgelöst.

> ⚠️ **Ohne `--mask` wird der Alphakanal der Textur selbst als Silhouette genommen** — richtig für eine Cutout-**Quelle**, aber falsch für ein gepacktes `_BCR`/`_NMO` (dort ist Alpha = Roughness/AO). In dem Fall immer die zugehörige Opacity-Maske übergeben.
>
> **Reimport-Falle:** Erzeugte PNGs müssen über die Workbench reimportiert werden. Die bestehende `<name>.edds.meta` liegen lassen → GUID bleibt erhalten → Material-Referenzen bleiben heil. PNG **niemals** einfach in `.edds` umbenennen.

## Robustheit

- **Atomare Schreibvorgänge:** erst temp, dann rename — kein halbes PNG landet im Addon.
- **Staging innerhalb des Addons** (gleiche Partition ⇒ echter Rename, kein EXDEV-Copy): der Normalfall ist ein einziger atomarer Verzeichnis-Rename.
- **Überschreibschutz:** existierende Zieldateien ⇒ Abbruch mit Kollisionsliste, außer `overwrite=True`.
- **Rollback mit Rettungsanker:** schlägt ein Overwrite mittendrin fehl, wird vollständig zurückgerollt; überschriebene Dateien liegen im `_backup` und werden nie stillschweigend gelöscht.
- **Kontaktblatt** landet neben `Data/`, nicht darin (unregistrierte PNGs im Asset-Baum erscheinen sonst als 0000-GUID-Transienten im Resource Browser).

---

## Projektstruktur

```
EnfusionTexKit/
├── texkit/                  # Rechenkern — importiert weder MCP noch CLI
│   ├── ops.py               # Box-Blur, Blur-Pyramide, Push-Pull-Inpaint, Resampling, atomares Speichern
│   ├── diffuse.py           # Edit-Diffuse (Licht/Schatten/Hotspot-Entfernung)
│   ├── height.py            # Height-from-Diffuse (Frequency-EQ)
│   ├── normal.py            # Normal-from-Height (Vorwärtsdifferenzen, DirectX-G)
│   ├── metallic.py          # Metallic-Heuristik (HSL-Distanz)
│   ├── roughness.py         # Roughness (invertierte Smoothness)
│   ├── ao.py                # Multi-Scale AO
│   ├── dilate.py            # Alpha-Dilation gegen den schwarzen Cutout-Saum (NN, scipy)
│   ├── edge.py              # Curvature/Edge-Map (fließt optional in Roughness)
│   ├── presets.py           # metall/stoff/foliage + Override-Auflösung
│   ├── packer.py            # BCR/NMO/Mask/Opacity-Packing
│   ├── emat.py              # .emat-Generator aus templates/
│   ├── contact.py           # Kanal-Kontaktblatt
│   ├── ingest.py            # Materialize-Export-Erkennung & -Übernahme
│   ├── validate.py          # Name/sub_path/preset-Validierung (von server.py & cli.py geteilt)
│   └── pipeline.py          # Orchestrierung: generate / material / finalize_material
├── templates/               # .emat-Vorlagen je Familie (aus Vanilla destilliert)
├── server.py                # MCP-Server (stdio)
├── cli.py                   # CLI-Zugang
├── tests/                   # Golden- & Invarianten-Tests, Dogtag-Fixture (61 Tests)
├── docs/                    # Spec, Plan, Backlog, Verifikation
└── LICENSE                  # GPL-3.0
```

---

## Tests

```bash
python -m pytest
```

- **Golden-Tests:** Referenztextur → SHA-256 der Maps; Algorithmus-Drift schlägt an.
- **Invarianten-Tests:** NMO außerhalb der Silhouette neutral, Roughness-Roundtrip, Kanalbelegung, Masken RGB-identisch mit A=255.
- **Realfall-Fixture:** der Dogtag-Durchlauf.

> `test_server_smoke` wird ohne `mcp` im Test-Interpreter übersprungen; der echte Regressionsschutz für `server.py` ist der uv-Smoke (`uv run --with mcp ... -c "import server"`).

**Status (2026-08-03):** v1 auf `master`, 61 Tests grün, MCP registriert. Engine-Parse live verifiziert (Workbench lädt/parst das erzeugte `.emat`). Offen: reine In-World-Sichtprüfung (Normal-Richtung/Glanz am Objekt im Licht).

---

## Status & Roadmap

Voller Backlog aus dem Final-Review: [`docs/backlog.md`](docs/backlog.md).

**v1.1 (Aufräumen):** ungenutzte `write_meta`/`texture.meta.tpl` markieren, Zweierpotenz-Warnung, `overlay_blur_frac` verdrahten oder entfernen.

**v2 (Substanz):** `.gamemat`/SurfaceProperties je Familie differenzieren, voll templategetriebene Slot-Ableitung in `finalize_material`, Ingest-Cutout-Support, weitere Familien (Leder/Holz/Gummi), Tiling-Maker, Alignment, GPU-Pfad, Workbench-Auto-Import-Verkettung, `.mtz`-Import.

---

## Lizenz

**GPL-3.0.** Die Map-Erzeugungs-Algorithmen sind Ports aus [Materialize](https://github.com/BoundingBoxSoftware/Materialize) (GPL-3.0), daher steht das gesamte Projekt unter GPL-3.0. Lokale Nutzung ist davon unberührt — die GPL-Pflichten greifen nur bei Weitergabe.

TDQS

A3.7/5.0

Scored across 6 tools

Disambiguation4/5

Tools are mostly distinct: tex_generate and tex_material both create maps, but one writes to a folder and the other stages into the addon, so purpose is clear. tex_pack vs tex_material both pack to BCR/NMO but differ in input source (existing maps vs full pipeline). Minor overlap exists but descriptions reduce ambiguity.

Naming Consistency4/5

All tools share the 'tex_' prefix and lowercase snake_case, making them predictable. However, the suffix is not uniformly a verb (params, material, emat are nouns), so it deviates slightly from a strict verb_noun pattern. Still consistent and readable.

Tool Count5/5

6 tools is well-scoped for the Enfusion texture pipeline, covering generation, parameter inspection, verification, packing, and .emat creation. No unnecessary bloat, each tool has a clear role.

Completeness5/5

The toolset covers the full workflow from source texture to final .emat, including experimental generation, inspection, and manual map packing. External workbench import is acknowledged, so no major gaps are apparent.

Maintenance

ActivitySlowing
ResponsivenessNo issues