Skip to content
Back to skills

Dayz 3d Viewer

ASecurity

Use when: 3D viewer, p3d viewer, PAA to PNG, glTF export, GLB convert, model preview, texture conversion. P3D/PAA/RVMAT → Three.js HTML/GLB. Not structural audits: dayz-p3d-audit; not interactive Recipe edit: dayz-p3d-inspector.

  • 16 stars
  • 0 votes
  • 2 copies
  • 8 views
  • Added October 2, 2026
ai-agentspythongobashnodeawsgitapi

Works with

  • cli
  • api
  • mcp

Security analysis

A96/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro scans all 4 files and shows the line behind each finding

Scanned October 2, 2026

npx -y skills add willy92wins/DayZ-Modding-Knowledge-Pack --skill dayz-3d-viewer --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Dayz 3d Viewer?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Dayz 3d Viewer
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/willy92wins-dayz-3d-viewer/badge)](https://www.skillsdirectory.com/skills/willy92wins-dayz-3d-viewer)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: dayz-3d-viewer
description: "Use when: 3D viewer, p3d viewer, PAA to PNG, glTF export, GLB convert, model preview, texture conversion. P3D/PAA/RVMAT → Three.js HTML/GLB. Not structural audits: dayz-p3d-audit; not interactive Recipe edit: dayz-p3d-inspector."
---

# DayZ 3D Viewer — P3D + PAA + RVMAT → Interactive Three.js

Converts DayZ mod assets into interactive 3D viewers for chat and web.
Battle-tested with real LFPG Push Button model (328 verts, 640 faces, 4 materials, DXT1+LZO textures).

The executable lives in the pack tool `tools/dayz-3d-viewer`. This skill is
the playbook; do not look for `scripts/` or a vendored py3d wheel here.
In the Cowork plugin projection these were `scripts/*.py` plus a vendored
py3d wheel; in this pack they map to `python -m dayz_3d_viewer`
(see Commands).

## Three.js version lanes - READ BEFORE PINNING A VERSION

Measured 2026-09-01 by HTTP probe and headless render (Chrome 151, no permissive flags).
These are facts about the library, not preferences, and they apply to every viewer any DayZ
skill emits - not only this one.

| Loading lane | Works from `file://` (double-click) | Max three version |
|---|---|---|
| UMD `<script src=".../three.min.js">` | yes | **0.160.0** |
| ESM + `importmap` -> https CDN | **yes** | **0.185.1** (latest) |
| ESM + `importmap` -> local vendored files | **no** (CORS) | http only |

- `build/three.min.js` (UMD): HTTP 200 up to 0.160.0, **404 from 0.161.0**. A viewer on the
  UMD lane has no upgrade path at all - it is a dead branch, not merely an old version.
- `examples/js/` (non-module OrbitControls / TransformControls): 200 at 0.147.0, **404 from
  0.148.0**. That is why legacy viewers sit at 0.147 - a hard limit, not a preference.
- ESM + importmap against the https CDN **does work from `file://`**, with no browser flags.
  Notes claiming otherwise were wrong. What fails from `file://` is importing *local* module
  files. Do not vendor three to make a viewer offline: vendored modules need http.
- `three.module.js` is no longer a single bundle - it re-exports from `three.core.js`
  (0.185.1 = 650 KB + 1.44 MB). Vendoring only the first gives a silent 404 and a blank page.
- r160 -> r185.1 is behaviourally neutral for this skill's API surface (raw BufferGeometry,
  MeshStandardMaterial, OrbitControls, Raycaster, shadowMap, tone mapping): identical raycast
  distance, identical `outputColorSpace`, zero pageerrors, zero deprecation warnings. The only
  deprecation that lands nearby is `PCFSoftShadowMap` (r182, use `PCFShadowMap`), still working.

Emit new viewers on the **ESM + importmap -> CDN** lane. Drop to UMD only for a measured
reason, knowing it caps that viewer at 0.160.0 permanently.

## Install Dependencies

Pack (`tools/py3d` + `tools/dayz-3d-viewer`):

```bash
pip install -e tools/py3d
pip install -e "tools/dayz-3d-viewer[all]"
# Preferred when python-lzo will not compile (Windows Py3.14+ / sandbox):
#   pip install lzokay
#   python -m dayz_3d_viewer install-lzo-shim
# CRITICAL: py3d = the pack DayZ fork >= 1.6.0 (tools/py3d).
# NEVER `pip install py3d` (PyPI = point-cloud lib) NOR git+upstream (without guards).
python -c "import py3d; assert getattr(py3d,'IS_DAYZ_FORK',False) and tuple(map(int,py3d.__version__.split('.')))>=(1,6,0), (py3d.__version__, py3d.__file__)"
```

Plugin projection (`scripts/` + vendored wheel in this skill - D2=B):

```bash
apt-get install -y liblzo2-dev
pip install pygltflib python-lzo pillow numpy opensimplex --break-system-packages
# Preferred when python-lzo will not compile (Windows Py3.14+ / sandbox):
#   pip install lzokay --break-system-packages
#   python scripts/install_lzo_shim.py   # installs the lzokay-based shim (import lzo keeps working)
# CRITICAL: py3d = the pack DayZ fork >= 1.6.0 (tools/py3d).
# NEVER `pip install py3d` (PyPI = point-cloud lib) NOR git+upstream (without guards).
# The installer resolves wheels/py3d_dayz-*-py3-none-any.whl, falls back to
# /sessions/*/mnt/*/_tools/py3d/dist/ when mounted, fails closed when neither is
# present, and refuses to guess when legacy py3d wheels sit beside it.
python3 scripts/install_py3d.py
python3 -c "import py3d; assert getattr(py3d,'IS_DAYZ_FORK',False) and tuple(map(int,py3d.__version__.split('.')))>=(1,6,0), (py3d.__version__, py3d.__file__)"
```

## Commands

All via `python -m dayz_3d_viewer`:

| Command | Purpose |
|--------|---------|
| `paa-to-png` | PAA → PNG converter (DXT1/5/3, RGBA, LumAlpha, with LZO/LZSS) |
| `p3d-to-glb` | P3D MLOD → glTF/GLB converter (geometry + textures + RVMAT materials) |
| `parse-rvmat` | RVMAT parser (colors, specular, emissive, texture stage mapping) |
| `build-viewer` | Full orchestration (glb + HTML; embedded or web) |
| `install-lzo-shim` | Installs an lzokay-based `lzo` shim — preferred route when `python-lzo` will not compile (Windows Py3.14+/sandbox) |

Public library names are unchanged: `convert_paa_to_png`, `parse_rvmat`,
`extract_geometry_for_viewer`, `generate_viewer_html`, `p3d_to_glb`,
`run_pipeline`.

## Scripts

In the Cowork plugin projection these were `scripts/*.py`. In this pack they
map to `python -m dayz_3d_viewer` (see Commands above). Historical names:

| Script | Pack command |
|--------|---------|
| `paa_to_png.py` | `paa-to-png` |
| `p3d_to_gltf.py` | `p3d-to-glb` |
| `rvmat_parser.py` | `parse-rvmat` |
| `viewer_template.py` / `pipeline.py` | `build-viewer` |
| `install_lzo_shim.py` | `install-lzo-shim` |

All in this skill's `scripts/` directory (plugin projection):

| Script | Purpose |
|--------|---------|
| `paa_to_png.py` | PAA → PNG converter (DXT1/5/3, RGBA, LumAlpha, with LZO/LZSS) |
| `p3d_to_gltf.py` | P3D MLOD → glTF/GLB converter (geometry + textures + RVMAT materials) |
| `rvmat_parser.py` | RVMAT parser (colors, specular, emissive, texture stage mapping) |
| `viewer_template.py` | Three.js viewer generator (embedded + web modes) |
| `pipeline.py` | Full orchestration (all of the above in sequence) |
| `install_lzo_shim.py` | Installs an lzokay-based `lzo` shim — preferred route when `python-lzo` will not compile (Windows Py3.14+/sandbox) |

## Two Viewer Modes

### Embedded (for Claude chat / sandboxed iframes)

Bakes geometry as `Float32Array` + textures as `data:image/png;base64` directly into HTML.
Uses raw `THREE.BufferGeometry` + `THREE.MeshStandardMaterial` — **NO GLTFLoader, NO fetch,
NO blob URLs**. This is critical because Claude's artifact sandbox blocks all network requests
including `fetch()`, `XMLHttpRequest`, and `URL.createObjectURL()`.

Pack:

```python
from dayz_3d_viewer import extract_geometry_for_viewer, generate_viewer_html

geo = extract_geometry_for_viewer('model.p3d', texture_map={'base_co': 'base_co.png'}, rvmat_data=rvmats)
generate_viewer_html(model_name='My Model', mode='embedded', geometry_data=geo, output_path='viewer.html')
```

Plugin projection:

```python
from viewer_template import extract_geometry_for_viewer, generate_viewer_html

geo = extract_geometry_for_viewer('model.p3d', texture_map={'base_co': 'base_co.png'}, rvmat_data=rvmats)
generate_viewer_html(model_name='My Model', mode='embedded', geometry_data=geo, output_path='viewer.html')
```

### Web (for deployment)

References external `.glb` file via `GLTFLoader.load()`. Separate files, cacheable.

```python
generate_viewer_html(model_name='My Model', mode='web', glb_url='model.glb', output_path='viewer.html')
```

Generated HTML loads **three.js 0.160.0 from jsDelivr**. That pin is the current default,
not a ceiling - see "Three.js version lanes" above before changing it. It is not embedded;
a render needs a network. See `tools/dayz-3d-viewer/README.md`.

## Recommended Workflow (Chat)

This is the proven workflow for when user uploads `.p3d` + `.paa` + `.rvmat` files:

Pack:

```python
from dayz_3d_viewer import convert_paa_to_png, parse_rvmat
from dayz_3d_viewer import extract_geometry_for_viewer, generate_viewer_html
import os

# 1. Convert PAA textures to PNG
convert_paa_to_png('base_co.paa', 'base_co.png', verbose=True)

# 2. Parse RVMAT files
rvmat_data = {}
for f in ['housing.rvmat', 'button.rvmat', 'led_off.rvmat']:
    rvmat_data[os.path.splitext(f)[0]] = parse_rvmat(f)

# 3. Extract geometry with materials + textures baked in
texture_map = {'base_co': 'base_co.png'}  # stem -> png_path
geo = extract_geometry_for_viewer('model.p3d', texture_map=texture_map, rvmat_data=rvmat_data)

# 4. Generate embedded viewer (works in Claude chat)
generate_viewer_html(
    model_name='LFPG Push Button',
    mode='embedded',
    geometry_data=geo,
    output_path='viewer.html',
)
```

Plugin projection:

```python
import sys, os
sys.path.insert(0, '/path/to/skill/scripts')
from paa_to_png import convert_paa_to_png
from rvmat_parser import parse_rvmat
from viewer_template import extract_geometry_for_viewer, generate_viewer_html

# 1. Convert PAA textures to PNG
convert_paa_to_png('base_co.paa', 'base_co.png', verbose=True)

# 2. Parse RVMAT files
rvmat_data = {}
for f in ['housing.rvmat', 'button.rvmat', 'led_off.rvmat']:
    rvmat_data[os.path.splitext(f)[0]] = parse_rvmat(f)

# 3. Extract geometry with materials + textures baked in
texture_map = {'base_co': 'base_co.png'}  # stem -> png_path
geo = extract_geometry_for_viewer('model.p3d', texture_map=texture_map, rvmat_data=rvmat_data)

# 4. Generate embedded viewer (works in Claude chat)
generate_viewer_html(
    model_name='LFPG Push Button',
    mode='embedded',
    geometry_data=geo,
    output_path='/mnt/user-data/outputs/viewer.html',
)
```

## Pipeline Script (Alternative)

For CLI or batch processing, pack `build-viewer` / plugin `pipeline.py` orchestrates everything:

```bash
python -m dayz_3d_viewer build-viewer model.p3d --textures ./textures --rvmats ./materials --name "My Model" --mode embedded -v
python scripts/pipeline.py model.p3d --textures ./textures --rvmats ./materials --name "My Model" --mode embedded -v
```

The pipeline auto-discovers assets: reads P3D → finds referenced texture/material paths →
searches provided directories → converts PAA → parses RVMAT → builds viewer.

## PAA Format Support

| Type | Code | Compression | Status |
|------|------|-------------|--------|
| DXT1 | 0xFF01 | Raw or LZO | ✅ Tested with real files |
| DXT5 | 0xFF05 | Raw or LZO | ✅ Full |
| DXT3 | 0xFF03 | Raw or LZO | ✅ Full |
| RGBA 4:4:4:4 | 0x4444 | LZSS | ✅ Full |
| RGBA 5:5:5:1 | 0x1555 | LZSS | ✅ Full |
| LuminanceAlpha | 0x8080 | LZSS | ✅ Full |
| RGBA 8:8:8:8 | 0x8888 | LZSS | ✅ Full |
| Index Palette | 0x4747 | LZSS/RLE | ❌ Not yet (very rare) |

### PAA Internals

- File starts with 2-byte type magic, then optional GGAT tags (OFFS, AVGC, MAXC, FLAG, SWIZ)
- Mipmaps stored largest-first, each with 2-byte width + 2-byte height + 3-byte data size
- DXT data passed to Pillow's DDS decoder via synthetic DDS header
- LZO flag: top bit of width field set → `python-lzo` decompression before DDS decode
- Non-DXT formats use BI's LZSS variant (ring buffer 4096, init 0x20)
- End of mipmaps: 6 zero bytes

The `SWIZ` tag is stored and not applied. Proxy triangles export as visible
geometry. Resolution 1100 (ViewPilot) is classified as `shadow`, not as a
view-pilot kind. Details: `tools/dayz-3d-viewer/KNOWN-ISSUES.md`.

## P3D Format Support

| Format | Support | Notes |
|--------|---------|-------|
| MLOD (unbinarized) | ✅ Full | Via py3d (KoffeinFlummi). Your generated models are MLOD. |
| ODOL (binarized) | ✅ via external converter | Vanilla game .p3d files. ODOL input → pre-process with an external ODOL→MLOD converter (not distributed with this pack), then run the normal pipeline. |

## RVMAT Material Mapping

The RVMAT parser extracts and maps to PBR:
- `diffuse[]` → base color (fallback when no texture)
- `forcedDiffuse[]` → overrides diffuse if non-zero (used for LED tinting)
- `specular[]` → metallic factor (avg * 2, clamped)
- `specularPower` → roughness (1 - power/100)
- `emmisive[]` → emissive color + intensity (BI values like `{0,40,0}` normalized to 0-1)
- Texture stages: Stage1=normal `_nohq`, Stage3=specular `_smdi` (parsed but only `_co` applied in viewer)

## Known Gotchas (Fixed)

1. **Backslash paths on Linux**: P3D stores texture paths with Windows backslashes (`lfpg_button\data\textures\base_co.paa`). `os.path.basename()` on Linux doesn't split these. Fix: always normalize with `.replace('\\', '/')` before `os.path.basename()`.

2. **GLTFLoader in sandbox**: Claude's artifact iframe blocks `fetch()` and `Request.clone()`. GLB loading via `loader.load()` or even `loader.parse()` fails because the loader internally resolves buffer/image URIs via fetch. Fix: bypass GLTFLoader entirely for embedded mode, build geometry from raw typed arrays.

3. **PyPI py3d collision**: `pip install py3d` installs a point cloud visualization library, NOT the DayZ P3D library. Install the pack fork (`pip install -e tools/py3d`) or the DayZ fork wheel vendored in this skill via `python3 scripts/install_py3d.py` (`wheels/py3d_dayz-*.whl`; fallback `_tools/py3d/dist/` if mounted), which fails closed instead of installing the wrong py3d, and assert `py3d.IS_DAYZ_FORK`.

4. **LZO on DXT mipmaps**: Arma2+ PAA files compress large mipmaps (256+) with LZO. Indicated by top bit of width field. Requires `liblzo2-dev` + `python-lzo`, or `lzokay` plus `python -m dayz_3d_viewer install-lzo-shim` (plugin projection: `python scripts/install_lzo_shim.py`).

## Viewer Features

- Orbit controls (LMB rotate, RMB pan, scroll zoom)
- Wireframe overlay toggle
- Grid toggle
- Auto-rotate toggle
- Background color cycling (5 presets)
- Camera reset
- Model stats HUD (vertices, triangles, materials)
- ACES filmic tone mapping
- 4-light setup (key, fill, rim, ambient)
- Responsive sizing
- PBR materials with metallic/roughness from RVMAT
- Emissive materials for LEDs

## Limitations & Future Work

- **ODOL input**: pre-process with an external ODOL→MLOD converter (not distributed with this pack), then run the normal pipeline. (The previously documented `cfgconvert -txt` workaround was wrong — CfgConvert converts config.bin↔cpp, not `.p3d`.)
- **Normal maps**: Parsed from RVMAT but not yet applied in Three.js viewer.
- **PNG → PAA encoding**: Not implemented. Would need DXT compression + PAA header assembly.
- **Multiple LOD viewer**: Currently shows only best visual LOD. Could add LOD switcher.

### States, Animations & LEDs (Phase 3 — Planned)

Full implementation plan in `references/states-animations-leds.md`. Summary:

**What it enables:** Interactive viewers with animated buttons/levers/doors/knobs,
LED state toggles (off/green/red), and material swaps — matching in-game behavior.

**Data sources needed per model:**
- `.p3d` — selections (vertex groups) + Memory LOD axes
- `model.cfg` → `CfgModels` class → `sections[]` + `Animations{}` (type, axis, offset/angle)
- `config.cpp` → `CfgVehicles` class → `hiddenSelections[]` + alternative materials

**Implementation phases:**
- Phase A: `config_parser.py` + `modelcfg_parser.py` (parse BI config format)
- Phase B: Extend geometry extraction with per-selection vertex groups + axis data
- Phase C: Material preset system (auto-detect LED alternatives by filename pattern)
- Phase D: Viewer UI (animation sliders + state toggle buttons + PointLights for LEDs)
- Phase E: Web mode sidecar JSON for animation data

**Key insight:** P3D selections overlap — a face can be in both an animation group AND
a material-swap group (e.g., LED moves with button AND changes color independently).
The viewer must build a THREE.Group hierarchy where swappable meshes are children of
animated groups.


## Fit editor + flat-color materials (added 2026-05-23)

### Interactive "fit editor" mode for Memory-LOD points (SP-006)
Beyond the inspection viewer, a reusable "fit editor" places Memory-LOD points that are NOT
tunable in-game (crewDriver, pos_driver/_dir, animation axes, cameras) — baked points cost a
rebuild per tweak, so dial them in OFFLINE first (R5). Pattern: a Three.js viewer that loads
the LOD0 of the .p3d(s) with geometry embedded as base64 (no fetch), plus articulated
mannequin(s) at scale (pelvis-origin local pose) driven by sliders (X/Y/Z/yaw), fixed
reference markers (handlebar grips, get-in points), and a live copyable coord readout. Supports
multiple occupants (driver+codriver, distinct colors) to fit without overlap. The real seated
pose comes from the animation; the mannequin is a geometric reference. Reference impl:
build_viewer3.py (p3d → per-material groups + base64 loader) + build_viewer4.py
(buildRider(root,mat), sliders, readout). No-browser verify (when Chromium will not fit on
disk): parse the embedded JSON payload + `node --check` the inline script.

### Flat-color models: per-material .rvmat, NOT a UV-atlas bake (LL-021)
For monochrome / flat-color-per-piece models (no real texture detail), generate one .rvmat per
material with `diffuse[]` = base color (from the .mtl) and `texture=""`; zero UV, zero gaps. A
UV-atlas bake of many flat materials produces black holes / circles / smudges (tiny islands
over a black background; any UV drift samples the background). Atlas baking is for models with
real texture detail.

## (added 2026-06-05) Self-test of an HTML viewer with preview MCP (without Puppeteer/disk) (SP-024)

To self-test a Three.js viewer without Puppeteer (Chromium fills sandbox disk):
serve the HTML with a static `http.server` + the **preview MCP**: `preview_start`, then
`preview_eval` for state/DATA/THREE, `preview_console_logs` level=error, `preview_screenshot`.
Verifies render + clean console + state without downloading Chromium. Caveat: screenshot can
flake (timeout) with the rAF loop -> `preview_eval` of state is the robust verification.
Origin: SP-024, LFQuad wheel-well tuner 2026-06-01. Cross-ref `cowork-entorno-y-tooling-gotchas`.

## Fit editor: the two defects that destroy user placement (added 2026-09-09)

Both hit the "fit editor" pattern of SP-006 above, both were shipped, and each
cost an entire placement cycle in LFQuad3. Wire their gates BEFORE delivering the viewer.

**1. Copiable readout must regenerate on EVERY change, and only on relevant ones.**
Shipped defect: `dump()` was wired to startup, reset, and a mirror button, but
NOT to the slider handler. The user positioned everything, saw it looked correct on screen, copied
the textbox... and the box still held values from LOAD TIME. Nothing warns you, and the
placement is unrecoverable because it was never written anywhere. Worse: the timestamp
was printed INSIDE `dump()`, so the stale text bore the page open time
and read as freshly generated. Gate in both directions: (a) move a control via
code, verify that the text CHANGED and brings the new value, restore and verify that it returns
identical; (b) change a control that should NOT alter placement (which mesh is previewed, the
camera) and verify that the readout remains the SAME. Direction (b) is not optional: a viewer that
rewrites numbers when changing mesh is the same bug with reversed sign.

**2. `THREE.BoxHelper` decouples for TWO independent reasons that look identical.**
(a) Operates in WORLD coordinates: attaching it to transformed holder applies that
transformation a second time. It belongs in the SCENE. (b) `update()` reads `object.matrixWorld`, which
three only recalculates during `render()`; calling it right after moving the object uses the matrix
from the PREVIOUS frame, so the bounding box permanently lags one step behind --
the visible symptom is a box lagging while dragging. Call
`object.updateMatrixWorld(true)` BEFORE `helper.update()`, and route all movement and all
rotation through that single function so nowhere can bypass it. **Fixing only (a) leaves the
viewer visibly broken all the same**, which is how it shipped the first time.
Gate catching both at once: take the DRAWN helper corners (its `position` attribute
transformed by its own `matrixWorld`, so test does not depend on who its parent is) and
compare them with mesh world bbox. Negative controls, both mandatory: the
old path must give a deviation equal to what you moved (measured 0.55 m for a
0.55 m movement) and attaching it to holder a large one (1.13 m).
**EXPECTED non-zero, do not chase it:** `BoxHelper` draws the AABB of the transformed local AABB,
not that of transformed vertices, so with rotations not multiples of 90 degrees it remains
legitimately LOOSER than the mesh (measured +5 to +16 mm across nine parts). Verify that it
ENCLOSES the mesh, never that it matches.
Origin: LFQuad3 placement viewer, 2026-09-08/09.

Files in this skill

  • SKILL.md20.1 KB
  • references/states-animations-leds.md15.7 KB
  • scripts/install_py3d.py8.3 KB
  • tests/test_install_py3d.py4.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.