UI language setting: English (default) / Nederlands, translation table, settings window, English README (Tim t1004u)
@@ -1,127 +1,131 @@
1
1
# Paperdoll Renderer
2
2
3
Desktopeditor voor `.ppd`-projecten, bone-gebonden equipment en geanimeerde 3D-preview, met productie-export naar paperdoll-maps.
3
Desktop editor for `.ppd` projects, bone-bound equipment and an animated 3D preview, with production export to paperdoll maps.
4
4
5
## Starten
5
## Getting started
6
6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity. Live 3D vereist OpenGL 3.3.
7
Rust 1.96.0 is pinned in `rust-toolchain.toml`. On Debian/Ubuntu, building and the window need `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` and `libgl1-mesa-dev`. The native file picker uses the XDG Desktop Portal with a GTK/KDE backend, or Zenity. Live 3D needs OpenGL 3.3.
8
8
9
9
```bash
10
10
cargo run
11
cargo run -- "/pad met spaties/character.ppd"
11
cargo run -- "/path with spaces/character.ppd"
12
12
cargo run -- --preview-fixture
13
13
cargo run -- --status
14
14
```
15
15
16
## Project en bodies
16
## Language and settings
17
17
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project vraagt een bovenliggende map (**Map kiezen…**) en een projectnaam, en maakt dan `<map>/<naam>/` aan. De naam mag niet leeg zijn, geen `/ \ < > : " | ? *` of stuurtekens bevatten en niet op een punt eindigen; bestaat de map al, dan moet die leeg zijn. Fouten staan in het formulier en er wordt dan niets aangemaakt. Daarin komen `character.ppd` (met die naam) en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. Onder de knoppen staan de laatste 5 geopende of aangemaakte projecten (**Recent geopend**); klik op een naam om het project te openen. Een project dat niet meer bestaat staat grijs met *niet gevonden*. De lijst staat in `recent.json` in de gebruikersconfiguratiemap (`$XDG_CONFIG_HOME/paperdoll-renderer/`, standaard `~/.config/paperdoll-renderer/`, onder Windows `%APPDATA%\paperdoll-renderer\`; aan te passen met `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
18
The UI is available in English (default) and Nederlands. **Settings** (on the start screen and in the project toolbar) holds the **Language** choice; switching takes effect immediately, including error and status messages. The choice is stored as `settings.json` in the user config directory (see below) and is restored at the next start. All UI text lives in one translation table (`src/i18n_table.rs`, Dutch source text → English, including format templates); tests check that every user-visible string has an entry in both languages with matching placeholders.
19
19
20
De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
20
## Project and bodies
21
21
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model, of een statisch lichaam (zie hieronder), is in de live preview zichtbaar:
22
The start screen offers **Load project** and **New project**, both with a system file picker. New project asks for a parent folder (**Choose folder…**) and a project name, and creates `<folder>/<name>/`. The name must not be empty, must not contain `/ \ < > : " | ? *` or control characters and must not end with a dot; if the folder already exists it must be empty. Errors are shown in the form and nothing is created. The new folder receives `character.ppd` (with that name) and the folders `assets/body/`, `assets/equipment/`, `presets/`, `cache/` and `renders/`. The CC0 test bodies are prepared in `assets/body/`. Below the buttons the last 5 opened or created projects are listed (**Recently opened**); click a name to open it. A project that no longer exists is greyed out with *not found*. The list is `recent.json` in the user config directory (`$XDG_CONFIG_HOME/paperdoll-renderer/`, by default `~/.config/paperdoll-renderer/`, on Windows `%APPDATA%\paperdoll-renderer\`; override with `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` creates a temporary sample project and opens the male body.
23
23
24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. **Omhullende animaties** berekent eenmalig een renderhoogte en Centrum Y waarbinnen alle clips, alle frames (op project-fps) en alle 60 hoeken vallen, met 5% marge aan elke kant; de pivot blijft staan. Daarna is het kader weer vast: er is geen automatische zoom per frame, clip of laag. Equipment dat verder uitsteekt dan het lichaam vraagt handmatig extra hoogte. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.
25
- Kies een animatieclip en gebruik **Pauze / Speel**.
26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van elk model. Vaste oriëntatie volgens de glTF-standaard (+Y omhoog, de voorkant kijkt naar +Z, de linkerzijde is +X); modellen en animaties worden as-is geladen, zonder automatische correctie: yaw 0 = voorkant (de rechterhand links in beeld), 90 = linkerzijde, 180 = achterkant, 270 = rechterzijde; positieve pitch kijkt van boven. Een test met gelabelde markers bewaakt deze afspraak.
27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.
28
- Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.
29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.
24
The first project view shows the bodies. **Choose male / Choose female** selects a model. An external file is copied into the project folder; if the name exists the copy gets its own name. Models with side files can be placed in the project folder with their relative folder structure and selected there. **Import overview** lists meshes, materials, textures, joints and clips of glTF/GLB, FBX and OBJ+MTL. FBX bodies are normalized once on load (right-handed Y-up, meters) and then drawn like glTF/GLB in the live 3D preview and production render, with all their animation stacks as clips.
30
25
31

26
**Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:
32
27
33
### Meerdere meshes en skins (glTF/GLB)
28
- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the editor uses the rest-pose neck height (or a fallback with a diagnostic); **Auto neck** clears a manual override. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
29
- Pick an animation clip and use **Pause / Play**.
30
- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31
- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32
- Set width, height and fps in the Bodies view. New projects start at 4 fps; an existing `.ppd` keeps its saved fps.
33
- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34
34
35
Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).
35

36
36
37
### Statische lichamen (OBJ, glTF/FBX zonder skin)
37
### Multiple meshes and skins (glTF/GLB)
38
38
39
Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:
39
A skinned glTF/GLB body is loaded completely: all mesh nodes and all skins. The joints of all skins are merged into one skeleton (joints present in several skins count once; at most 128). A mesh node without a skin moves rigidly with the nearest ancestor that is a joint; if it has none, it hangs from the first joint. Its rest position in the scene is kept exactly (mirrored transforms flip the triangle winding). Materials stay per primitive: base color factor × albedo texture, alpha cutoff and vertex colors; every primitive uses its own material, in the live preview and in production. External image files (`uri` next to the `.gltf`) are resolved relative to the model, like embedded and `data:` images. `KHR_texture_transform` on the base color texture (offset, rotation, scale and `texCoord`) is applied once to the UVs on load. The sampler wrap mode is not read: textures are always sampled nearest with REPEAT, so CLAMP/MIRROR look different outside the 0–1 UV range. Metallic/roughness, normal, occlusion and emissive textures and morph targets are not used; the normal map comes from geometry and a body's specular map is zero (metallic/roughness is not converted; a scalar specular cannot reproduce PBR physically).
40
40
41
- OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.
42
- De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.
43
- **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.
44
- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 60 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie, plus atlassen en `.ppda`; de set opent via `export_set` en de 2D-preview.
45
- Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.
41
### Static bodies (OBJ, glTF/FBX without skin)
46
42
47
## 2D-preview
43
A body without a skin can also be chosen as male or female model: **OBJ+MTL** (with PNG textures next to the model), **glTF/GLB without `skins`** and **FBX without skin deformer**. Such a body is shown and rendered as stored, without skeleton or animation:
48
44
49
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over de resultaatmappen `renders/<geslacht>-<settype>-atlas/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.
45
- OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.
46
- The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.
47
- **Pivot Y:** without a neck bone the camera uses **Center Y** as orbit point (diagnostic *Static body without skeleton*); a manual **Pivot Y** works as for skinned bodies. Fixed render height/zoom per body stays the same.
48
- **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.
49
- Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.
50
50
51
- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.
52
- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.
53
- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −60 / −30 / 0 / +30 / +60 met klemmen (start op 0). Hoekwijzigingen behouden de animatietijd.
54
- Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen per pixel de winnende laag van de dieptecompositie, onbelicht en precies zoals in de atlas opgeslagen, zonder decodering: diffuse, normal en specular als hun RGBA, depth als zijn opgeslagen kanalen (`gray` als grijs, `rgb_bands` en `rgb_packed` als ruwe RGB). 16-bit depth wordt op gewone weergaveprecisie (8 bit per kanaal, afgerond) getoond. Alleen Resultaat decodeert de diepte voor compositie en belichting. Ook het live passbeeld tijdens het renderen toont de depth-map precies zoals hij geschreven wordt (zelfde weergaveregel). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.
55
- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. De knoppen volgen de echte muisknopstatus, dus ze blijven draaien zolang je vasthoudt (ook langer dan een seconde). Loslaten, van de knop af bewegen, focusverlies of de pointer die het venster verlaat stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.
56
- Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).
57
- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.
51
## 2D preview
58
52
59

53
From **Body** or the body view, **2D preview** opens the rendered compositor over the result folders `renders/<sex>-<settype>-atlas/`. The preview reads every export through its `.ppda` and the four atlases (validated, see *Atlases and .ppda*), not through the loose PNGs. Runs without a valid `.ppda` are skipped; their count is shown below the export counter, with the reason on hover. The decoded atlases of the current selection stay in memory until you choose another selection or use **Refresh export sets**.
54
55
- Choose **Male / Female**. Only export sets with the same project/rig/export contract (`CompatKey`) can be combined; equipment appears as a multi-select when those contracts match.
56
- Choose a clip from the body export inventory. **Play / Pause**, speed and export fps drive a looping frame time (elapsed × fps); missing maps/frames show a diagnostic instead of a silent substitute.
57
- **Yaw − / Yaw +** wrap over 0…330° (step 30). **Pitch − / Pitch +** choose −60 / −30 / 0 / +30 / +60 with clamping (start at 0). Angle changes keep the animation time.
58
- Choose view mode **Result** (default; lit composition), **Diffuse only**, **Normal map only**, **Depth map only** or **Specular map only**. Map modes show, per pixel, the winning layer of the depth composition, unlit and exactly as stored in the atlas, without decoding: diffuse, normal and specular as their RGBA, depth as its stored channels (`gray` as gray, `rgb_bands` and `rgb_packed` as raw RGB). 16-bit depth is shown at plain display precision (8 bits per channel, rounded). Only Result decodes depth for composition and lighting. The live pass image during rendering also shows the depth map exactly as it is written (same display rule). Playback, angle and equipment work in every mode; light only in Result.
59
- Hold **Azimuth / Elevation** for continuous light rotation in °/s (delta time); azimuth wraps over 360° without clamping, elevation stays within −80…+80. The buttons follow the real mouse button state, so they keep turning while held (also longer than a second). Releasing, moving off the button, losing focus or the pointer leaving the window stops the motion. Lighting is for Result only; stored diffuse remains unlit albedo.
60
- Playback runs over exactly `frames / fps` seconds, so every exported frame (including the last) lasts equally long (`1/fps`).
61
- The composed frame uses nearest-neighbor display with uniform scale and centered letterboxing, like the production pass previews.
62
63

60
64
61
65
```bash
62
# Native GUI-smoke (Xvfb): synthetische export, losse PNG's verwijderd, preview via .ppda/atlassen
66
# Native GUI smoke (Xvfb): synthetic export, loose PNGs removed, preview via .ppda/atlases
63
67
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
64
# Zelfde smoke op een bestaand project met echte exports
68
# Same smoke on an existing project with real exports
65
69
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live
66
70
```
67
71
68
## Equipment en opslaan
72
## Equipment and saving
69
73
70
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
74
From a body, **Equipment** opens the list. Choose a definition or **New**.
71
75
72
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
73
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
74
3. **Onderdeel toevoegen (OBJ/FBX)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. Rigide (niet-geskinde) FBX-onderdelen krijgen dezelfde as- en eenheidsnormalisatie als FBX-lichamen; in de FBX ingebedde PNG-textures en relatieve PNG-textures naast het model worden in het preset ingesloten. Een gekoppelde diffuse texture vervangt de FBX-diffusekleur. Geskinde FBX-meshes, morph targets en niet-PNG-textures worden met een melding geweigerd. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.
75
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.
76
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.
77
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.
76
1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
77
2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
78
3. **Add part (OBJ/FBX)** opens the system file picker. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Skinned FBX meshes, morph targets and non-PNG textures are refused with a message. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.
79
4. Expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.
80
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.
81
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings, sex flags and origin). No source paths outside the project are needed to reload them.
78
82
79
**Preset importeren** leest een `.ppe` uit bijvoorbeeld een ander project. Bij een andere rig moet je de botten opnieuw kiezen; er wordt niet stil geretarget. Onvolledige configuraties kunnen worden opgeslagen, maar onopgeloste onderdelen worden niet op een gegist bot getekend. **Verwijder onderdeel** haalt een onderdeel uit het werkpreset; **Verwijder equipmentdefinitie** haalt de definitie uit de projectlijst. Oude project-snapshots blijven voor herstel bewaard. Opslaan vereist een bestandssysteem met hardlinks; bij een fout wordt een bestaande snapshot niet overschreven.
83
**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.
80
84
81
Een preset is begrensd op 32 MiB, 32 onderdelen/bestanden en textures van maximaal 2048×2048. Een project heeft maximaal 64 presets en 64 MiB aan presetbestanden. De volledige containerbeschrijving staat in `design.md`.
85
A preset is limited to 32 MiB, 32 parts/files and textures of at most 2048×2048. A project has at most 64 presets and 64 MiB of preset files. The full container description is in `design.md`.
82
86
83
87
```bash
84
# Zelfbevattend CC0-zwaardvoorbeeld in een nieuw tijdelijk project
88
# Self-contained CC0 sword sample in a new temporary project
85
89
cargo run --example equipment_fixture --locked
86
90
```
87
91
88

92

89
93
90
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.
94
When closing or choosing **Other project / New project** with changes, choose **Save**, **Don't save** or **Cancel**. If saving fails, the project stays open. Cancelling the file picker keeps your current work.
91
95
92
## Modellen in het project
96
## Models in the project
93
97
94
**Kies man/vrouw** kopieert een model van buiten het project naar een eigen map `assets/body/<naam>/`, samen met alle bijbehorende bestanden: bij glTF de externe buffers en afbeeldingen (`uri`, behalve `data:`), bij OBJ de `mtllib`-bestanden en de texturen daaruit (`map_*`, `bump`, `norm`). De relatieve indeling blijft gelijk. Ontbreekt er een bijbehorend bestand, of wijst een verwijzing buiten de modelmap (`..`, absoluut pad), dan wordt niets gekopieerd en noemt de melding de bestanden. Naast het model komt `<model>.provenance.json` met het bronpad en de SHA-256 van elk gekopieerd bestand. Een tweede keer hetzelfde model kiezen maakt `<naam>-2/`; niets wordt overschreven. Een model dat al in het project staat wordt op zijn plaats gebruikt. Kan een projectmodel niet laden, dan noemt de foutmelding ook de ontbrekende bijbehorende bestanden. Assen, eenheden en windingvolgorde worden bij het laden één keer genormaliseerd (FBX via ufbx); de bronbestanden blijven ongewijzigd.
98
**Choose male/female** copies a model from outside the project into its own folder `assets/body/<name>/`, together with all its side files: for glTF the external buffers and images (`uri`, except `data:`), for OBJ the `mtllib` files and their textures (`map_*`, `bump`, `norm`). The relative layout is kept. If a side file is missing, or a reference points outside the model folder (`..`, absolute path), nothing is copied and the message names the files. Next to the model goes `<model>.provenance.json` with the source path and the SHA-256 of every copied file. Choosing the same model again creates `<name>-2/`; nothing is overwritten. A model already in the project is used in place. If a project model fails to load, the error also names missing side files. Axes, units and winding are normalized once on load (FBX via ufbx); the source files stay unchanged.
95
99
96
## Animaties
100
## Animations
97
101
98
Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.
102
All clips from the source file are used. glTF interpolation is kept: LINEAR (linear, rotations via slerp), STEP (holds the previous key) and CUBICSPLINE (Hermite with in/out tangents, scaled by the key interval; rotations normalized). Channels with non-increasing or non-finite times, non-finite values or a wrong number of values make loading fail with a clear message instead of silently playing wrong. Morph target weights are not applied; this is reported per clip when rendering. Unnamed clips are called `animatie <index>`; duplicate names stay separate (production chooses clips by index). In the live preview an exact clip name wins over a partial match. Root motion is kept as in the source. FBX clips are baked by ufbx and played linearly.
99
103
100
## Productie-output
104
## Production output
101
105
102
### Render alles
106
### Render all
103
107
104
**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Tijdens Render alles blijft de live passpreview in het Body-scherm zichtbaar en volgt die het item dat op dat moment rendert, net als bij een losse render. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.
108
**Render all** in the Body screen renders the whole project in one queue: first the male and female body (if set), then every equipment preset for each sex it is valid for. Each item gets the full export (all clips × frames × 60 angles, four maps, atlases + `.ppda`) into its own `renders/<sex>-<settype>-atlas/`. Progress shows *item k/n* plus the normal pass count; **Cancel render** stops the queue (the running item stays resumable, the rest is marked *not started*). If an item has a matching interrupted run, it is resumed instead of restarted. Items that cannot run (no body, equipment not configured or without parts, invalid settings) are skipped with a reason and the queue continues. During Render all the live pass preview in the Body screen stays visible and follows the item currently rendering, as with a single render. Afterwards a summary lists what was rendered, skipped, failed or not started.
105
109
106
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.
110
The body button renders only the body; **Render equipment · all angles** in the equipment editor renders the selected preset, animated on the chosen body but **without body occlusion or body pixels**. The fixed camera of that sex stays the same, also if you zoomed freely in the live preview.
107
111
108
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineair binnen een vast dieptebereik per geslacht, gecentreerd op het orbitpunt: `near/far = 10 m ∓ totaal/2`. Standaard is het totaal de T-pose-spanwijdte plus één armlengte marge aan elke kant (arm = afstand bovenarm→handbot × 1,2, spanwijdte = schouderbreedte + 2 armen, onafhankelijk van de bindpose; zonder herkenbare arm-/handbotten: spanwijdte = max(breedte, hoogte) en arm = 0,4·spanwijdte). In het body-scherm staat onder het renderkader **Dieptebereik**: *automatisch* of een eigen totaal in meters (`depth_range_milli` per geslacht in de `.ppd`), met de resulterende near/far. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik.
112
Per image **only four target-res PNGs** are stored: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **unlit albedo** (base color factor × texture × vertex color, alpha kept), never baked lighting/shadow/gloss. Depth is linear within a fixed depth range per sex, centered on the orbit point: `near/far = 10 m ∓ total/2`. By default the total is the T-pose arm span plus one arm length of margin on each side (arm = upper arm→hand bone distance × 1.2, span = shoulder width + 2 arms, independent of the bind pose; without recognizable arm/hand bones: span = max(width, height) and arm = 0.4·span). In the body screen, below the render frame, **Depth range** is *automatic* or a custom total in meters (`depth_range_milli` per sex in the `.ppd`), with the resulting near/far. All clips, frames, angles and equipment of that sex share the same range.
109
113
110
Het depthformaat kies je in het Body-scherm (**Depth-map**), opgeslagen in de `.ppd` als `render.depth_bits` (8, standaard, of 16) en `render.depth_encoding`. Met `t = (afstand − near)/(far − near)` en M = 2^bits − 1:
111
- `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.
112
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).
113
- `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.
114
Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.
114
The depth format is chosen in the Body screen (**Depth map**), stored in the `.ppd` as `render.depth_bits` (8, default, or 16) and `render.depth_encoding`. With `t = (distance − near)/(far − near)` and M = 2^bits − 1:
115
- `gray` (default): gray+alpha, `gray = round(t·M)`.
116
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 levels at 8 bit).
117
- `rgb_packed`: RGBA, positional in base 2^bits (24-bit or 48-bit value), R most significant; needs nearest sampling, no mipmaps and lossless storage.
118
Alpha is kept (scaled to the bit depth); transparent pixels have value 0. Geometry outside the range is clamped (not clipped) and reported in the progress; normal is view-space RGB+alpha; specular is linear strength (body zero, equipment including the configured shiny map). Highres and detail are visible intermediate steps only and are not stored.
115
119
116
120
```text
117
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
118
renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
121
renders/male-body-atlas/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
122
renders/male-body-atlas/male-body-diffuse-atlas.png (and -normal, -depth, -specular)
119
123
renders/male-body-atlas/male-body-atlas.ppda
120
124
```
121
125
122
Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding.
126
Per sex and set type there is one result folder `renders/<sex>-<settype>-atlas/` holding only the newest complete render. A render is built in a temporary folder `renders/inprogress-<time>-<pid>-<sex>-<settype>/`. Only when it is complete and the atlases/`.ppda` are verified is the previous result folder moved aside (`.replaced-…`), the new one renamed into place and the old one deleted. On cancel, error or crash the last good result folder therefore stays. An interrupted swap is repaired at the next render: a moved-aside old folder comes back, and a finished but not yet placed render is placed. Old `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a message.
123
127
124
Een voltooide resultaatmap bevat alleen uitvoer voor de gebruiker:
128
A finished result folder contains only consumer output:
125
129
126
130
```
127
131
renders/male-body-atlas/
@@ -134,37 +138,37 @@ renders/male-body-atlas/
134
138
male/body/<clip>/f000000/p-60_y000_diffuse.png …
135
139
```
136
140
137
Alle losse beelden per frame staan gegroepeerd onder `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` en de markers van eerdere pogingen bestaan alleen in de tijdelijke `inprogress-*`-map; ze worden verwijderd nadat de render gecontroleerd is en vóór de map op zijn plaats komt. Wat daarna nog nodig is (het exportcontract en de SHA-256 van iedere losse PNG) staat in de `.ppda`. Alleen na beschadiging tijdens een hervatte render kan er nog een bewaard `.corrupt-aNNN-K`-bestand naast staan. Oudere resultaatmappen (losse beelden en `render.json`/`complete.json` direct in de map) worden nog gelezen.
141
All loose per-frame images are grouped under `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` and the markers of earlier attempts exist only in the temporary `inprogress-*` folder; they are removed after the render is verified and before the folder is put in place. What is still needed afterwards (the export contract and the SHA-256 of every loose PNG) is in the `.ppda`. Only after damage during a resumed render can a kept `.corrupt-aNNN-K` file remain. Older result folders (loose images and `render.json`/`complete.json` directly in the folder) are still read.
138
142
139
Tijdens het bouwen: `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. In de tijdelijke map betekent alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) een volledige set. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` leest een voltooide resultaatmap via de `.ppda` (oudere mappen via hun `render.json`/`complete.json`), opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.
143
While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) describes project identity, source fingerprints (SHA-256 of the body file and optionally the encoded equipment), camera/pivot/sample/material contract (pitches −60…+60, yaws 0…330), clip inventory and count. In the temporary folder only `complete.json` (same schema, with per-PNG SHA-256 and the names and SHA-256 of atlases and `.ppda`) marks a complete set. Schema 5 (before `depth_bits`) is still read as 16-bit gray. Schema 4 and older (including the old global 0.05–40 m range) are refused by the readers and not mixed; render those again. `cancelled.json`/`failed.json` mark a partial run without a complete marker. The library module `export_set` reads a finished result folder through the `.ppda` (older folders through their `render.json`/`complete.json`), opens only finished runs with matching identity and verified checksums, and reads the real RGBA8 and depth maps (all six formats, converted to 16-bit depth for the compositor) with strict path/format validation. The map/camera/pixel contracts are in `design.md`.
140
144
141
### Atlassen en .ppda
145
### Atlases and .ppda
142
146
143
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json` (alleen in de tijdelijke map).
147
When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).
144
148
145
- Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.
146
- De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.
147
- Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.
148
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 4 (versies 2–3 worden nog gelezen; versie 2 als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels) en `export` (vanaf versie 4: het volledige exportcontract, voorheen `render.json`, het aantal passes en `png_sha256`, de SHA-256 per losse PNG onder `raw files/`).
149
- De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.
149
- A cell is exactly the target resolution, without border or scaling. Each row is one (clip, frame) in source order. The 60 columns are pitch −60→+60 and within that yaw 0→330. The four atlases share the same cell coordinates. Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.
150
- The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.
151
- If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.
152
- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 4 (versions 2–3 are still read; version 2 as 16-bit gray), `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h}` in atlas pixels) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).
153
- The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, cell coordinates off the grid and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps; `cell()` crops one angle. `atlas::cross_check_run` compares every atlas cell with the loose PNG.
150
154
151
### Hervatten
155
### Resuming
152
156
153
Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is. Start je een nieuwe (niet hervatte) render van hetzelfde geslacht en settype, dan worden oudere onvoltooide `inprogress-*`-mappen van dat doel eerst verwijderd, zodat alleen de nieuwste overblijft; de voltooide resultaatmap, andere doelen en een map waarin op dat moment gerenderd wordt blijven staan.
157
A resumed run continues writing in the same temporary `inprogress-*` folder; the result folder is untouched until the run is done. If you start a new (not resumed) render of the same sex and set type, older unfinished `inprogress-*` folders of that target are deleted first, so only the newest remains; the finished result folder, other targets and a folder currently being rendered into are kept.
154
158
155
- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.
156
- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.
157
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.
158
- Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).
159
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven. Daarna worden deze administratiebestanden opgeruimd (`complete.json` als laatste) en komt de map op zijn plaats. Een crash tussen opruimen en plaatsen wordt bij de volgende render hersteld: de voltooide map wordt alsnog opgeschoond en geplaatst.
159
- Every run keeps `progress.jsonl`: per published PNG the path and SHA-256. `run.lock` prevents two renders writing into the same run at once.
160
- Resuming is only possible if `render.json` exactly matches what the current job would write: project, sex/set, SHA-256 of body and equipment, target, fps, framing/pivot, camera, clips and maps. A finished, locked or different run is refused.
161
- Before rendering, every planned PNG is checked. Files with a matching checksum stay unchanged and are not rendered again. Missing files are recreated. Damaged files are renamed to `<name>.corrupt-aNNN-K` and then recreated; they are not deleted. An existing file without a journal line (crash between writing and recording) stays if the re-rendered bytes are equal. Leftover `.part-*` files are not touched.
162
- After the loose maps, the atlases and `.ppda` are written (see above).
163
- Progress starts at the work that is already correct; skipped images are labelled *already present, checksums match*. You can cancel again and resume later. A previous `cancelled.json`/`failed.json` is kept as `attempt-NNN-…json`, and `resume.jsonl` records per attempt what was verified, missing and quarantined. Only when everything matches is `complete.json` with all checksums written. Then these bookkeeping files are cleaned up (`complete.json` last) and the folder is put in place. A crash between cleanup and placement is repaired at the next render: the finished folder is cleaned and placed.
160
164
161

165

162
166
163
## 2D depth-compositor (bibliotheek)
167
## 2D depth compositor (library)
164
168
165
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris, gelezen uit `export_set` of uit de `.ppda`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).
169
The `compositor` module combines a body export with zero or more compatible equipment exports (same project id, sex, body fingerprint, target, fps, framing, near/far and clip inventory, read from `export_set` or from the `.ppda`). Per pixel the nearest valid surface wins (depth gray closer = smaller; epsilon `DEPTH_EPSILON_U16 = 1` depth code; at equal depth the lowest layer index wins — pass the body first, then equipment in fixed name order). Diffuse, normal and specular always come from the **same** winning layer. Soft alpha of that winner is kept; losing layers do not mix in (single depth cannot reconstruct arbitrary translucency).
166
170
167
`relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.
171
`relight` / `compose_and_relight` applies linear ambient + Lambert diffuse + Blinn-Phong specular. The light vector is already in view space, like the exported normal maps. There is no rim light: the preview shows only this relight in Result mode. Stored diffuse remains unlit albedo elsewhere.
168
172
169
173
```rust
170
174
use paperdoll_renderer::compositor::{
@@ -181,45 +185,44 @@ let layers = [
181
185
];
182
186
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
183
187
184
// Zelfde lagen via de consumentenvorm (.ppda + atlassen):
188
// Same layers via the consumer form (.ppda + atlases):
185
189
use paperdoll_renderer::atlas::AtlasSet;
186
190
let body = AtlasSet::open_run(&body_run_dir)?.load()?;
187
191
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
188
192
```
189
193
194
Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, neck pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.
190
195
191
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert alle glTF/GLB-meshes met driehoekprimitieven (zie *Meerdere meshes en skins*), basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.
192
193
## Openen met
196
## Open with
194
197
195
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).
196
- **Debian/Arch:** de pakketten installeren de `.ppd`-MIME-koppeling, desktopstarter en iconen. Kies de editor in **Openen met**.
197
- **Linux AppImage/losse binary:** bewaar het bestand op zijn vaste plek en voer `"/pad/Paperdoll.AppImage" --install-desktop` uit. Hiervoor zijn `shared-mime-info` en `desktop-file-utils` nodig. Daarna staat de editor in **Openen met**. Herhaal dit na verplaatsen van de AppImage.
198
- **Windows:** run the unpacked `.exe` once to add the editor to *Open with* for the current account. Then choose **Open with → Paperdoll Renderer** on a `.ppd` (or select the `.exe` via Browse).
199
- **Debian/Arch:** the packages install the `.ppd` MIME association, desktop launcher and icons. Choose the editor in **Open with**.
200
- **Linux AppImage/plain binary:** keep the file in its permanent location and run `"/path/Paperdoll.AppImage" --install-desktop`. This needs `shared-mime-info` and `desktop-file-utils`. The editor then appears in **Open with**. Repeat after moving the AppImage.
198
201
199
Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon. Het venster heeft app-id `paperdoll-renderer` (gelijk aan `paperdoll-renderer.desktop` en `StartupWMClass`), zodat KDE en GNOME onder Wayland het venster aan de starter en het icoon koppelen; de app draait onder Wayland native en onder X11 via X11. Onder Wayland verschijnt het icoon pas als de desktopfile geïnstalleerd is (pakket, of bestandskoppeling vanuit de app).
202
The window, the Linux launcher and the Windows executable use Tim's application icon. The window has app id `paperdoll-renderer` (equal to `paperdoll-renderer.desktop` and `StartupWMClass`), so KDE and GNOME under Wayland map the window to the launcher and icon; the app runs natively under Wayland and via X11 under X11. Under Wayland the icon only appears once the desktop file is installed (package, or file association from the app).
200
203
201
204
## Tests
202
205
203
`tests/robustness.rs` bewaakt dat beschadigde invoer een gewone foutmelding geeft en nooit een crash: honderden deterministische mutaties (afgekapt, bitflips, nul- en 0xFF-blokken) van de glTF/GLB-, FBX- en OBJ-lichamen, het equipment-preset en de projectmanifest, kapotte `.ppda`-bestanden, en glTF-nodes met schaal 0 of een overlopende schaal. Bij het laden van glTF worden niet-eindige posities/normalen/gewichten, verkeerde attribuutlengtes en indices buiten het vertexbereik geweigerd; een paniek in de glTF-parser wordt een laadfout.
206
`tests/robustness.rs` makes sure damaged input gives an ordinary error and never a crash: hundreds of deterministic mutations (truncation, bit flips, zero and 0xFF blocks) of the glTF/GLB, FBX and OBJ bodies, the equipment preset and the project manifest, broken `.ppda` files, and glTF nodes with scale 0 or an overflowing scale. When loading glTF, non-finite positions/normals/weights, wrong attribute lengths and indices outside the vertex range are refused; a panic in the glTF parser becomes a load error.
204
207
205
208
```bash
206
209
cargo fmt --check
207
210
cargo check --workspace --all-targets --locked
208
211
cargo clippy --workspace --all-targets --locked -- -D warnings
209
212
cargo test --workspace --locked # incl. tests/robustness.rs
210
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo
213
# Linux GPU regression: real depth occlusion, normal/depth/specular and light-independent albedo
211
214
xvfb-run -a cargo run --example preview_depth_probe --locked
212
# Productie op echte GPU (body of equipment); controleert daarna iedere atlascel tegen de losse PNG
215
# Production on a real GPU (body or equipment); then checks every atlas cell against the loose PNG
213
216
xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4
214
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set- en atlascontrole
217
# Resuming on a real GPU: setup, render with crash (abort) or cancel, resume, export_set and atlas check
215
218
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2
216
219
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
217
220
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
218
221
cargo run --release --example resume_fixture --locked -- verify /tmp/resume
219
# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset
222
# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set
220
223
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
221
# Statische lichamen op echte GPU: OBJ-man en glTF-vrouw zonder skin, live view, equipment-weigering, productie en export_set
224
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set
222
225
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
223
226
```
224
227
225
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd). De statische lichamen in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG en een GLB zonder skin) komen uit `scripts/generate_static_bodies.py`.
228
The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.
@@ -1,127 +1,131 @@1
# Paperdoll Renderer1
# Paperdoll Renderer2
2
3
Desktopeditor voor `.ppd`-projecten, bone-gebonden equipment en geanimeerde 3D-preview, met productie-export naar paperdoll-maps.3
Desktop editor for `.ppd` projects, bone-bound equipment and an animated 3D preview, with production export to paperdoll maps.4
4
5
## Starten5
## Getting started6
6
7
Rust 1.96.0 staat in `rust-toolchain.toml`. Op Debian/Ubuntu gebruik je voor bouwen en het venster `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` en `libgl1-mesa-dev`. De native filepicker gebruikt XDG Desktop Portal met een GTK/KDE-backend, of Zenity. Live 3D vereist OpenGL 3.3.7
Rust 1.96.0 is pinned in `rust-toolchain.toml`. On Debian/Ubuntu, building and the window need `pkg-config`, `libxcb1-dev`, `libx11-dev`, `libxkbcommon-dev`, `libxkbcommon-x11-dev` and `libgl1-mesa-dev`. The native file picker uses the XDG Desktop Portal with a GTK/KDE backend, or Zenity. Live 3D needs OpenGL 3.3.8
8
9
```bash9
```bash10
cargo run10
cargo run11
cargo run -- "/pad met spaties/character.ppd"11
cargo run -- "/path with spaces/character.ppd"12
cargo run -- --preview-fixture12
cargo run -- --preview-fixture13
cargo run -- --status13
cargo run -- --status14
```14
```15
15
16
## Project en bodies16
## Language and settings17
17
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project vraagt een bovenliggende map (**Map kiezen…**) en een projectnaam, en maakt dan `<map>/<naam>/` aan. De naam mag niet leeg zijn, geen `/ \ < > : " | ? *` of stuurtekens bevatten en niet op een punt eindigen; bestaat de map al, dan moet die leeg zijn. Fouten staan in het formulier en er wordt dan niets aangemaakt. Daarin komen `character.ppd` (met die naam) en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. Onder de knoppen staan de laatste 5 geopende of aangemaakte projecten (**Recent geopend**); klik op een naam om het project te openen. Een project dat niet meer bestaat staat grijs met *niet gevonden*. De lijst staat in `recent.json` in de gebruikersconfiguratiemap (`$XDG_CONFIG_HOME/paperdoll-renderer/`, standaard `~/.config/paperdoll-renderer/`, onder Windows `%APPDATA%\paperdoll-renderer\`; aan te passen met `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.18
The UI is available in English (default) and Nederlands. **Settings** (on the start screen and in the project toolbar) holds the **Language** choice; switching takes effect immediately, including error and status messages. The choice is stored as `settings.json` in the user config directory (see below) and is restored at the next start. All UI text lives in one translation table (`src/i18n_table.rs`, Dutch source text → English, including format templates); tests check that every user-visible string has an entry in both languages with matching placeholders.19
19
20
De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.20
## Project and bodies21
21
22
**Open man / Open vrouw** opent de body. Een geskind glTF/GLB- of FBX-model, of een statisch lichaam (zie hieronder), is in de live preview zichtbaar:22
The start screen offers **Load project** and **New project**, both with a system file picker. New project asks for a parent folder (**Choose folder…**) and a project name, and creates `<folder>/<name>/`. The name must not be empty, must not contain `/ \ < > : " | ? *` or control characters and must not end with a dot; if the folder already exists it must be empty. Errors are shown in the form and nothing is created. The new folder receives `character.ppd` (with that name) and the folders `assets/body/`, `assets/equipment/`, `presets/`, `cache/` and `renders/`. The CC0 test bodies are prepared in `assets/body/`. Below the buttons the last 5 opened or created projects are listed (**Recently opened**); click a name to open it. A project that no longer exists is greyed out with *not found*. The list is `recent.json` in the user config directory (`$XDG_CONFIG_HOME/paperdoll-renderer/`, by default `~/.config/paperdoll-renderer/`, on Windows `%APPDATA%\paperdoll-renderer\`; override with `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` creates a temporary sample project and opens the male body.23
23
24
- Sleep om te orbiten. **Vast renderkader tonen** toont het gele orthografische exportkader; stel per man/vrouw **Vaste renderhoogte**, **Centrum Y** (beeldcentrum) en **Pivot Y** (orbitpunt) in en sla op. Zonder handmatige pivot gebruikt de editor de rustpose-nekhoogte (of een fallback met diagnose); **Auto nek** wist een handmatige override. **Omhullende animaties** berekent eenmalig een renderhoogte en Centrum Y waarbinnen alle clips, alle frames (op project-fps) en alle 60 hoeken vallen, met 5% marge aan elke kant; de pivot blijft staan. Daarna is het kader weer vast: er is geen automatische zoom per frame, clip of laag. Equipment dat verder uitsteekt dan het lichaam vraagt handmatig extra hoogte. Body en equipment delen exact dezelfde camera. Uitvinken geeft vrije perspectief-preview met scrollzoom; die zoom verandert de export niet. Een echte 24-bits depthbuffer houdt voorste oppervlakken vooraan.24
The first project view shows the bodies. **Choose male / Choose female** selects a model. An external file is copied into the project folder; if the name exists the copy gets its own name. Models with side files can be placed in the project folder with their relative folder structure and selected there. **Import overview** lists meshes, materials, textures, joints and clips of glTF/GLB, FBX and OBJ+MTL. FBX bodies are normalized once on load (right-handed Y-up, meters) and then drawn like glTF/GLB in the live 3D preview and production render, with all their animation stacks as clips.25
- Kies een animatieclip en gebruik **Pauze / Speel**.26
- **Yaw − / Yaw +** volgen twaalf hoeken van 30°; **pitch -60 / -30 / 0 / 30 / 60** kiezen de hoogte. Yaw 0 kijkt naar de voorkant van elk model. Vaste oriëntatie volgens de glTF-standaard (+Y omhoog, de voorkant kijkt naar +Z, de linkerzijde is +X); modellen en animaties worden as-is geladen, zonder automatische correctie: yaw 0 = voorkant (de rechterhand links in beeld), 90 = linkerzijde, 180 = achterkant, 270 = rechterzijde; positieve pitch kijkt van boven. Een test met gelabelde markers bewaakt deze afspraak.27
- **Render alle hoeken** rendert alle animaties × alle frames op project-fps × 60 hoeken (5 pitches × 12 yaws). Na de losse maps schrijft hij per maptype een atlas plus een `.ppda`-bestand (zie *Atlassen en .ppda*). De preview wordt direct vervangen door iedere voltooide pass: highres → detail → target/diffuse → depth → normal → specular. Alle passes behouden hun aspect met gecentreerde letterboxing en nearest-neighbor weergave. Voortgang telt echte passes; **Annuleer render** behoudt geldige gedeeltelijke uitvoer. **Live 3D** keert terug naar het model; een volgende voltooide renderpass kan de preview weer vervangen.28
- Stel breedte, hoogte en fps in de Bodies-weergave in. Nieuwe projecten beginnen op 4 fps; een bestaand `.ppd` houdt zijn opgeslagen fps.29
- **Zoek onderbroken runs** (ook in de equipment-editor) toont geannuleerde, mislukte of afgebroken runs van dezelfde body/equipmentset met het aantal vastgelegde PNG's. **Hervat** gaat alleen verder als bronbestanden en instellingen exact gelijk zijn aan die run; anders staat erbij wat er verschilt. Na annuleren verschijnt de run direct in deze lijst. Zie *Hervatten* hieronder.30
25
31
26
**Open male / Open female** opens the body. A skinned glTF/GLB or FBX model, or a static body (see below), is shown in the live preview:32
27
33
### Meerdere meshes en skins (glTF/GLB)28
- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the editor uses the rest-pose neck height (or a fallback with a diagnostic); **Auto neck** clears a manual override. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.29
- Pick an animation clip and use **Pause / Play**.30
- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.31
- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.32
- Set width, height and fps in the Bodies view. New projects start at 4 fps; an existing `.ppd` keeps its saved fps.33
- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.34
34
35
Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).35
36
36
37
### Statische lichamen (OBJ, glTF/FBX zonder skin)37
### Multiple meshes and skins (glTF/GLB)38
38
39
Een lichaam zonder skin kan ook als man- of vrouwmodel gekozen worden: **OBJ+MTL** (met PNG-textures naast het model), **glTF/GLB zonder `skins`** en **FBX zonder skin deformer**. Zo'n lichaam wordt getoond en gerenderd zoals het is opgeslagen, zonder skelet of animatie:39
A skinned glTF/GLB body is loaded completely: all mesh nodes and all skins. The joints of all skins are merged into one skeleton (joints present in several skins count once; at most 128). A mesh node without a skin moves rigidly with the nearest ancestor that is a joint; if it has none, it hangs from the first joint. Its rest position in the scene is kept exactly (mirrored transforms flip the triangle winding). Materials stay per primitive: base color factor × albedo texture, alpha cutoff and vertex colors; every primitive uses its own material, in the live preview and in production. External image files (`uri` next to the `.gltf`) are resolved relative to the model, like embedded and `data:` images. `KHR_texture_transform` on the base color texture (offset, rotation, scale and `texCoord`) is applied once to the UVs on load. The sampler wrap mode is not read: textures are always sampled nearest with REPEAT, so CLAMP/MIRROR look different outside the 0–1 UV range. Metallic/roughness, normal, occlusion and emissive textures and morph targets are not used; the normal map comes from geometry and a body's specular map is zero (metallic/roughness is not converted; a scalar specular cannot reproduce PBR physically).40
40
41
- OBJ en FBX lopen door dezelfde rigide lezer als equipment (FBX met dezelfde as-/eenheidsnormalisatie, één keer); bij glTF worden de node-transformaties van de scène één keer in de geometrie verwerkt (gespiegelde transformaties draaien de driehoeksvolgorde om). Animaties in een glTF zonder skin worden niet toegepast; de melding noemt hoeveel.41
### Static bodies (OBJ, glTF/FBX without skin)42
- De bodyweergave meldt dat het lichaam statisch is. De animatiekeuze en **Speel/Pauze** zijn uitgeschakeld.43
- **Pivot Y:** zonder nekbot gebruikt de camera **Centrum Y** als orbitpunt (diagnose *Statisch lichaam zonder skeleton*); een handmatige **Pivot Y** werkt zoals bij geskinde lichamen. Vaste renderhoogte/zoom per lichaam blijft gelijk.44
- **Render alle hoeken** maakt één bindpose-sample `clip000-bind` × 60 hoeken met dezelfde vier maps (diffuse als onbelichte albedo, normal, depth, specular) op doelresolutie, plus atlassen en `.ppda`; de set opent via `export_set` en de 2D-preview.45
- Equipment heeft een bot nodig: in de equipment-editor zijn **Skeleton / botten** en **Render equipment · alle hoeken** uitgeschakeld met uitleg, en de live view tekent geen equipment op een statisch lichaam. Een equipmentrender op een statisch lichaam wordt geweigerd.46
42
47
## 2D-preview43
A body without a skin can also be chosen as male or female model: **OBJ+MTL** (with PNG textures next to the model), **glTF/GLB without `skins`** and **FBX without skin deformer**. Such a body is shown and rendered as stored, without skeleton or animation:48
44
49
Vanuit **Body** of de bodyweergave opent **2D-preview** de gerenderde compositor over de resultaatmappen `renders/<geslacht>-<settype>-atlas/`. De preview leest iedere export via zijn `.ppda` en de vier atlassen (gevalideerd, zie *Atlassen en .ppda*), niet via de losse PNG's. Runs zonder geldige `.ppda` worden overgeslagen; het aantal staat onder de exportteller, met de reden bij hover. De gedecodeerde atlassen van de huidige selectie blijven in het geheugen tot je een andere selectie kiest of **Vernieuw exportsets** gebruikt.45
- OBJ and FBX go through the same rigid reader as equipment (FBX with the same axis/unit normalization, once); for glTF the scene's node transforms are baked into the geometry once (mirrored transforms flip the winding). Animations in a glTF without skin are not applied; the message says how many.46
- The body view reports that the body is static. The animation choice and **Play/Pause** are disabled.47
- **Pivot Y:** without a neck bone the camera uses **Center Y** as orbit point (diagnostic *Static body without skeleton*); a manual **Pivot Y** works as for skinned bodies. Fixed render height/zoom per body stays the same.48
- **Render all angles** makes one bind-pose sample `clip000-bind` × 60 angles with the same four maps (diffuse as unlit albedo, normal, depth, specular) at target resolution, plus atlases and `.ppda`; the set opens through `export_set` and the 2D preview.49
- Equipment needs a bone: in the equipment editor **Skeleton / bones** and **Render equipment · all angles** are disabled with an explanation, and the live view draws no equipment on a static body. An equipment render on a static body is refused.50
50
51
- Kies **Man / Vrouw**. Alleen exportsets met dezelfde project-/rig-/exportcontract (`CompatKey`) zijn combineerbaar; equipment verschijnt als multiselect wanneer die contracten overeenkomen.51
## 2D preview52
- Kies een clip uit de body-exportinventaris. **Speel / Pauze**, snelheid en export-fps sturen een looping frame-tijd (elapsed × fps); ontbrekende maps/frames tonen een diagnose in plaats van een stille vervanging.53
- **Yaw − / Yaw +** wrappen over 0…330° (stap 30). **Pitch − / Pitch +** kiezen −60 / −30 / 0 / +30 / +60 met klemmen (start op 0). Hoekwijzigingen behouden de animatietijd.54
- Kies weergavestand **Resultaat** (standaard; belichte compositie), **Alleen diffuse**, **Alleen normal map**, **Alleen depth map** of **Alleen specular map**. Mapstanden tonen per pixel de winnende laag van de dieptecompositie, onbelicht en precies zoals in de atlas opgeslagen, zonder decodering: diffuse, normal en specular als hun RGBA, depth als zijn opgeslagen kanalen (`gray` als grijs, `rgb_bands` en `rgb_packed` als ruwe RGB). 16-bit depth wordt op gewone weergaveprecisie (8 bit per kanaal, afgerond) getoond. Alleen Resultaat decodeert de diepte voor compositie en belichting. Ook het live passbeeld tijdens het renderen toont de depth-map precies zoals hij geschreven wordt (zelfde weergaveregel). Playback, hoek en equipment werken in elke stand; licht alleen in Resultaat.55
- Houd **Azimut / Elevatie** ingedrukt voor continue lichtrotatie in °/s (delta-time); azimut wrapt over 360° zonder klem, elevatie blijft −80…+80. De knoppen volgen de echte muisknopstatus, dus ze blijven draaien zolang je vasthoudt (ook langer dan een seconde). Loslaten, van de knop af bewegen, focusverlies of de pointer die het venster verlaat stopt de beweging. Belichting is alleen voor Resultaat; opgeslagen diffuse blijft unlit albedo.56
- Playback loopt over exact `frames / fps` seconden, zodat ieder geëxporteerd frame (inclusief het laatste) even lang duurt (`1/fps`).57
- Het samengestelde frame gebruikt nearest-neighbor weergave met uniforme schaal en gecentreerde letterboxing, gelijk aan de productie-passpreviews.58
52
59
53
From **Body** or the body view, **2D preview** opens the rendered compositor over the result folders `renders/<sex>-<settype>-atlas/`. The preview reads every export through its `.ppda` and the four atlases (validated, see *Atlases and .ppda*), not through the loose PNGs. Runs without a valid `.ppda` are skipped; their count is shown below the export counter, with the reason on hover. The decoded atlases of the current selection stay in memory until you choose another selection or use **Refresh export sets**.54
55
- Choose **Male / Female**. Only export sets with the same project/rig/export contract (`CompatKey`) can be combined; equipment appears as a multi-select when those contracts match.56
- Choose a clip from the body export inventory. **Play / Pause**, speed and export fps drive a looping frame time (elapsed × fps); missing maps/frames show a diagnostic instead of a silent substitute.57
- **Yaw − / Yaw +** wrap over 0…330° (step 30). **Pitch − / Pitch +** choose −60 / −30 / 0 / +30 / +60 with clamping (start at 0). Angle changes keep the animation time.58
- Choose view mode **Result** (default; lit composition), **Diffuse only**, **Normal map only**, **Depth map only** or **Specular map only**. Map modes show, per pixel, the winning layer of the depth composition, unlit and exactly as stored in the atlas, without decoding: diffuse, normal and specular as their RGBA, depth as its stored channels (`gray` as gray, `rgb_bands` and `rgb_packed` as raw RGB). 16-bit depth is shown at plain display precision (8 bits per channel, rounded). Only Result decodes depth for composition and lighting. The live pass image during rendering also shows the depth map exactly as it is written (same display rule). Playback, angle and equipment work in every mode; light only in Result.59
- Hold **Azimuth / Elevation** for continuous light rotation in °/s (delta time); azimuth wraps over 360° without clamping, elevation stays within −80…+80. The buttons follow the real mouse button state, so they keep turning while held (also longer than a second). Releasing, moving off the button, losing focus or the pointer leaving the window stops the motion. Lighting is for Result only; stored diffuse remains unlit albedo.60
- Playback runs over exactly `frames / fps` seconds, so every exported frame (including the last) lasts equally long (`1/fps`).61
- The composed frame uses nearest-neighbor display with uniform scale and centered letterboxing, like the production pass previews.62
63
60
64
61
```bash65
```bash62
# Native GUI-smoke (Xvfb): synthetische export, losse PNG's verwijderd, preview via .ppda/atlassen66
# Native GUI smoke (Xvfb): synthetic export, loose PNGs removed, preview via .ppda/atlases63
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke67
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke64
# Zelfde smoke op een bestaand project met echte exports68
# Same smoke on an existing project with real exports65
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live69
cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live66
```70
```67
71
68
## Equipment en opslaan72
## Equipment and saving69
73
70
Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.74
From a body, **Equipment** opens the list. Choose a definition or **New**.71
75
72
1. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.76
1. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.73
2. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.77
2. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.74
3. **Onderdeel toevoegen (OBJ/FBX)** opent de systeembestandkiezer. Rigide OBJ-onderdelen worden inclusief MTL en relatieve PNG-bijbestanden ingesloten. Rigide (niet-geskinde) FBX-onderdelen krijgen dezelfde as- en eenheidsnormalisatie als FBX-lichamen; in de FBX ingebedde PNG-textures en relatieve PNG-textures naast het model worden in het preset ingesloten. Een gekoppelde diffuse texture vervangt de FBX-diffusekleur. Geskinde FBX-meshes, morph targets en niet-PNG-textures worden met een melding geweigerd. De preview gebruikt diffuse kleur/texture en alpha-cutout (grens 0,5). Bronbestanden mogen de modelmap niet verlaten.78
3. **Add part (OBJ/FBX)** opens the system file picker. Rigid OBJ parts are embedded including MTL and relative PNG side files. Rigid (unskinned) FBX parts get the same axis and unit normalization as FBX bodies; PNG textures embedded in the FBX and relative PNG textures next to the model are embedded in the preset. A linked diffuse texture replaces the FBX diffuse color. Skinned FBX meshes, morph targets and non-PNG textures are refused with a message. The preview uses diffuse color/texture and alpha cutout (threshold 0.5). Source files may not leave the model folder.75
4. Vouw **Skeleton / botten** uit en kies het doelbot. Stel relatieve positie XYZ en rotatie XYZ in graden in (Rz·Ry·Rx). Het onderdeel beweegt met dat bot tijdens walk/attack of een andere geïmporteerde bodyclip. Iedere sekse heeft een eigen botbinding; de numerieke offsets zijn gedeeld.79
4. Expand **Skeleton / bones** and choose the target bone. Set relative position XYZ and rotation XYZ in degrees (Rz·Ry·Rx). The part moves with that bone during walk/attack or another imported body clip. Each sex has its own bone binding; the numeric offsets are shared.76
5. **Kies shinymap (PNG)** moduleert de instelbare glanssterkte. **Wis shinymap** keert terug naar een uniforme glanssterkte. Vul desgewenst **Herkomst / licentie** in. Meerdere onderdelen kunnen verschillende botten gebruiken.80
5. **Choose shiny map (PNG)** modulates the adjustable gloss strength. **Clear shiny map** returns to a uniform gloss strength. Optionally fill in **Origin / license**. Several parts can use different bones.77
6. **Opslaan** bewaart het project plus zelfbevattende binaire `.ppe`-snapshots in `presets/` (geometrie, bronpayloads, materialen/textures, transformaties, bindingen, seksevlaggen en herkomst). Er worden geen bronpaden buiten het project nodig om die opnieuw te laden.81
6. **Save** stores the project plus self-contained binary `.ppe` snapshots in `presets/` (geometry, source payloads, materials/textures, transforms, bindings, sex flags and origin). No source paths outside the project are needed to reload them.78
82
79
**Preset importeren** leest een `.ppe` uit bijvoorbeeld een ander project. Bij een andere rig moet je de botten opnieuw kiezen; er wordt niet stil geretarget. Onvolledige configuraties kunnen worden opgeslagen, maar onopgeloste onderdelen worden niet op een gegist bot getekend. **Verwijder onderdeel** haalt een onderdeel uit het werkpreset; **Verwijder equipmentdefinitie** haalt de definitie uit de projectlijst. Oude project-snapshots blijven voor herstel bewaard. Opslaan vereist een bestandssysteem met hardlinks; bij een fout wordt een bestaande snapshot niet overschreven.83
**Import preset** reads a `.ppe` from, for example, another project. With a different rig you must choose the bones again; there is no silent retargeting. Incomplete configurations can be saved, but unresolved parts are not drawn on a guessed bone. **Remove part** removes a part from the working preset; **Remove equipment definition** removes the definition from the project list. Old project snapshots are kept for recovery. Saving requires a file system with hard links; on failure an existing snapshot is not overwritten.80
84
81
Een preset is begrensd op 32 MiB, 32 onderdelen/bestanden en textures van maximaal 2048×2048. Een project heeft maximaal 64 presets en 64 MiB aan presetbestanden. De volledige containerbeschrijving staat in `design.md`.85
A preset is limited to 32 MiB, 32 parts/files and textures of at most 2048×2048. A project has at most 64 presets and 64 MiB of preset files. The full container description is in `design.md`.82
86
83
```bash87
```bash84
# Zelfbevattend CC0-zwaardvoorbeeld in een nieuw tijdelijk project88
# Self-contained CC0 sword sample in a new temporary project85
cargo run --example equipment_fixture --locked89
cargo run --example equipment_fixture --locked86
```90
```87
91
88
92
89
93
90
Bij sluiten of **Ander project / Nieuw project** met wijzigingen kies je **Opslaan**, **Niet opslaan** of **Annuleren**. Als opslaan mislukt, blijft het project open. Annuleren in de bestandkiezer behoudt je huidige werk.94
When closing or choosing **Other project / New project** with changes, choose **Save**, **Don't save** or **Cancel**. If saving fails, the project stays open. Cancelling the file picker keeps your current work.91
95
92
## Modellen in het project96
## Models in the project93
97
94
**Kies man/vrouw** kopieert een model van buiten het project naar een eigen map `assets/body/<naam>/`, samen met alle bijbehorende bestanden: bij glTF de externe buffers en afbeeldingen (`uri`, behalve `data:`), bij OBJ de `mtllib`-bestanden en de texturen daaruit (`map_*`, `bump`, `norm`). De relatieve indeling blijft gelijk. Ontbreekt er een bijbehorend bestand, of wijst een verwijzing buiten de modelmap (`..`, absoluut pad), dan wordt niets gekopieerd en noemt de melding de bestanden. Naast het model komt `<model>.provenance.json` met het bronpad en de SHA-256 van elk gekopieerd bestand. Een tweede keer hetzelfde model kiezen maakt `<naam>-2/`; niets wordt overschreven. Een model dat al in het project staat wordt op zijn plaats gebruikt. Kan een projectmodel niet laden, dan noemt de foutmelding ook de ontbrekende bijbehorende bestanden. Assen, eenheden en windingvolgorde worden bij het laden één keer genormaliseerd (FBX via ufbx); de bronbestanden blijven ongewijzigd.98
**Choose male/female** copies a model from outside the project into its own folder `assets/body/<name>/`, together with all its side files: for glTF the external buffers and images (`uri`, except `data:`), for OBJ the `mtllib` files and their textures (`map_*`, `bump`, `norm`). The relative layout is kept. If a side file is missing, or a reference points outside the model folder (`..`, absolute path), nothing is copied and the message names the files. Next to the model goes `<model>.provenance.json` with the source path and the SHA-256 of every copied file. Choosing the same model again creates `<name>-2/`; nothing is overwritten. A model already in the project is used in place. If a project model fails to load, the error also names missing side files. Axes, units and winding are normalized once on load (FBX via ufbx); the source files stay unchanged.95
99
96
## Animaties100
## Animations97
101
98
Alle clips uit het bronbestand worden gebruikt. glTF-interpolatie blijft behouden: LINEAR (lineair, rotaties via slerp), STEP (houdt de vorige key vast) en CUBICSPLINE (Hermite met in/uit-tangenten, geschaald met het key-interval; rotaties genormaliseerd). Kanalen met niet-oplopende of niet-eindige tijden, niet-eindige waarden of een verkeerd aantal waarden laten het laden mislukken met een duidelijke melding in plaats van stil verkeerd af te spelen. Morph-target-gewichten worden niet toegepast; dat wordt per clip gemeld bij het renderen. Clips zonder naam heten `animatie <index>`; dubbele namen blijven apart (productie kiest clips op index). In de live preview wint een exacte clipnaam boven een gedeeltelijke match. Rootmotion blijft behouden zoals in de bron. FBX-clips worden door ufbx gebakken en lineair afgespeeld.102
All clips from the source file are used. glTF interpolation is kept: LINEAR (linear, rotations via slerp), STEP (holds the previous key) and CUBICSPLINE (Hermite with in/out tangents, scaled by the key interval; rotations normalized). Channels with non-increasing or non-finite times, non-finite values or a wrong number of values make loading fail with a clear message instead of silently playing wrong. Morph target weights are not applied; this is reported per clip when rendering. Unnamed clips are called `animatie <index>`; duplicate names stay separate (production chooses clips by index). In the live preview an exact clip name wins over a partial match. Root motion is kept as in the source. FBX clips are baked by ufbx and played linearly.99
103
100
## Productie-output104
## Production output101
105
102
### Render alles106
### Render all103
107
104
**Render alles** in het Body-scherm rendert het hele project in één wachtrij: eerst het mannelijke en vrouwelijke lichaam (als ingesteld), daarna ieder equipment-preset voor elk geslacht waarvoor het geldt. Elk item krijgt de volledige export (alle clips × frames × 60 hoeken, vier maps, atlassen + `.ppda`) naar zijn eigen `renders/<geslacht>-<settype>-atlas/`. De voortgang toont *item k/n* plus de gewone passtelling; **Annuleer render** stopt de wachtrij (het lopende item blijft hervatbaar, de rest staat als *niet gestart*). Heeft een item een passende onderbroken run, dan wordt die hervat in plaats van opnieuw begonnen. Items die niet kunnen (geen lichaam, equipment niet geconfigureerd of zonder onderdelen, ongeldige instellingen) worden met reden overgeslagen en de wachtrij gaat door. Tijdens Render alles blijft de live passpreview in het Body-scherm zichtbaar en volgt die het item dat op dat moment rendert, net als bij een losse render. Na afloop staat een samenvatting met wat gerenderd, overgeslagen, mislukt of niet gestart is.108
**Render all** in the Body screen renders the whole project in one queue: first the male and female body (if set), then every equipment preset for each sex it is valid for. Each item gets the full export (all clips × frames × 60 angles, four maps, atlases + `.ppda`) into its own `renders/<sex>-<settype>-atlas/`. Progress shows *item k/n* plus the normal pass count; **Cancel render** stops the queue (the running item stays resumable, the rest is marked *not started*). If an item has a matching interrupted run, it is resumed instead of restarted. Items that cannot run (no body, equipment not configured or without parts, invalid settings) are skipped with a reason and the queue continues. During Render all the live pass preview in the Body screen stays visible and follows the item currently rendering, as with a single render. Afterwards a summary lists what was rendered, skipped, failed or not started.105
109
106
De bodyknop rendert alleen het lichaam; **Render equipment alle hoeken** in de equipment-editor rendert het geselecteerde preset, geanimeerd op het gekozen lichaam maar **zonder body-occlusie of bodypixels**. De vaste camera van dat geslacht blijft gelijk, ook als je in de live preview vrij hebt ingezoomd.110
The body button renders only the body; **Render equipment · all angles** in the equipment editor renders the selected preset, animated on the chosen body but **without body occlusion or body pixels**. The fixed camera of that sex stays the same, also if you zoomed freely in the live preview.107
111
108
Per beeld worden **alleen vier target-res PNGs** opgeslagen: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **onbelichte albedo** (basiskleur/texture × vertexkleur, alpha behouden), nooit ingebakken lighting/schaduw/glans. Depth is lineair binnen een vast dieptebereik per geslacht, gecentreerd op het orbitpunt: `near/far = 10 m ∓ totaal/2`. Standaard is het totaal de T-pose-spanwijdte plus één armlengte marge aan elke kant (arm = afstand bovenarm→handbot × 1,2, spanwijdte = schouderbreedte + 2 armen, onafhankelijk van de bindpose; zonder herkenbare arm-/handbotten: spanwijdte = max(breedte, hoogte) en arm = 0,4·spanwijdte). In het body-scherm staat onder het renderkader **Dieptebereik**: *automatisch* of een eigen totaal in meters (`depth_range_milli` per geslacht in de `.ppd`), met de resulterende near/far. Alle clips, frames, hoeken en equipment van dat geslacht delen hetzelfde bereik.112
Per image **only four target-res PNGs** are stored: `diffuse`, `normal`, `depth`, `specular`. Diffuse is **unlit albedo** (base color factor × texture × vertex color, alpha kept), never baked lighting/shadow/gloss. Depth is linear within a fixed depth range per sex, centered on the orbit point: `near/far = 10 m ∓ total/2`. By default the total is the T-pose arm span plus one arm length of margin on each side (arm = upper arm→hand bone distance × 1.2, span = shoulder width + 2 arms, independent of the bind pose; without recognizable arm/hand bones: span = max(width, height) and arm = 0.4·span). In the body screen, below the render frame, **Depth range** is *automatic* or a custom total in meters (`depth_range_milli` per sex in the `.ppd`), with the resulting near/far. All clips, frames, angles and equipment of that sex share the same range.109
113
110
Het depthformaat kies je in het Body-scherm (**Depth-map**), opgeslagen in de `.ppd` als `render.depth_bits` (8, standaard, of 16) en `render.depth_encoding`. Met `t = (afstand − near)/(far − near)` en M = 2^bits − 1:114
The depth format is chosen in the Body screen (**Depth map**), stored in the `.ppd` as `render.depth_bits` (8, default, or 16) and `render.depth_encoding`. With `t = (distance − near)/(far − near)` and M = 2^bits − 1:111
- `gray` (standaard): grijs+alpha, `grijs = round(t·M)`.115
- `gray` (default): gray+alpha, `gray = round(t·M)`.112
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 niveaus bij 8-bit).116
- `rgb_bands`: RGBA, `v = round(t·3M)`, R = min(v, M), G = clamp(v − M, 0, M), B = clamp(v − 2M, 0, M); t = (R+G+B)/3M (766 levels at 8 bit).113
- `rgb_packed`: RGBA, positioneel in basis 2^bits (24-bit of 48-bit waarde), R hoogste deel; vereist nearest sampling, geen mipmaps en lossless opslag.117
- `rgb_packed`: RGBA, positional in base 2^bits (24-bit or 48-bit value), R most significant; needs nearest sampling, no mipmaps and lossless storage.114
Alpha blijft behouden (geschaald naar de bitdiepte); transparante pixels hebben waarde 0. Geometrie buiten het bereik wordt geklemd (niet weggeknipt) en gemeld in de voortgang; normal is view-space RGB+alpha; specular is lineaire sterkte (body nul, equipment inclusief ingestelde shinymap). Highres en detail zijn uitsluitend zichtbare hulpstappen en worden niet opgeslagen.118
Alpha is kept (scaled to the bit depth); transparent pixels have value 0. Geometry outside the range is clamped (not clipped) and reported in the progress; normal is view-space RGB+alpha; specular is linear strength (body zero, equipment including the configured shiny map). Highres and detail are visible intermediate steps only and are not stored.115
119
116
```text120
```text117
renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png121
renders/male-body-atlas/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png118
renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -specular)122
renders/male-body-atlas/male-body-diffuse-atlas.png (and -normal, -depth, -specular)119
renders/male-body-atlas/male-body-atlas.ppda123
renders/male-body-atlas/male-body-atlas.ppda120
```124
```121
125
122
Per geslacht en settype is er één resultaatmap `renders/<geslacht>-<settype>-atlas/` met alleen de nieuwste volledige render. Een render wordt opgebouwd in een tijdelijke map `renders/inprogress-<tijd>-<pid>-<geslacht>-<settype>/`. Pas als die volledig is en de atlassen/`.ppda` gecontroleerd zijn, wordt de vorige resultaatmap opzijgezet (`.replaced-…`), de nieuwe op zijn plaats hernoemd en de oude verwijderd. Bij annuleren, een fout of een crash blijft de laatste goede resultaatmap dus staan. Een onderbroken wissel wordt bij de volgende render hersteld: een opzijgezette oude map komt terug, en een voltooide maar nog niet geplaatste render wordt alsnog geplaatst. Oude `run-*`-mappen van eerdere versies worden niet automatisch verwijderd; de 2D-preview negeert ze met een melding.126
Per sex and set type there is one result folder `renders/<sex>-<settype>-atlas/` holding only the newest complete render. A render is built in a temporary folder `renders/inprogress-<time>-<pid>-<sex>-<settype>/`. Only when it is complete and the atlases/`.ppda` are verified is the previous result folder moved aside (`.replaced-…`), the new one renamed into place and the old one deleted. On cancel, error or crash the last good result folder therefore stays. An interrupted swap is repaired at the next render: a moved-aside old folder comes back, and a finished but not yet placed render is placed. Old `run-*` folders from earlier versions are not deleted automatically; the 2D preview ignores them with a message.123
127
124
Een voltooide resultaatmap bevat alleen uitvoer voor de gebruiker:128
A finished result folder contains only consumer output:125
129
126
```130
```127
renders/male-body-atlas/131
renders/male-body-atlas/@@ -134,37 +138,37 @@ renders/male-body-atlas/134
male/body/<clip>/f000000/p-60_y000_diffuse.png …138
male/body/<clip>/f000000/p-60_y000_diffuse.png …135
```139
```136
140
137
Alle losse beelden per frame staan gegroepeerd onder `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` en de markers van eerdere pogingen bestaan alleen in de tijdelijke `inprogress-*`-map; ze worden verwijderd nadat de render gecontroleerd is en vóór de map op zijn plaats komt. Wat daarna nog nodig is (het exportcontract en de SHA-256 van iedere losse PNG) staat in de `.ppda`. Alleen na beschadiging tijdens een hervatte render kan er nog een bewaard `.corrupt-aNNN-K`-bestand naast staan. Oudere resultaatmappen (losse beelden en `render.json`/`complete.json` direct in de map) worden nog gelezen.141
All loose per-frame images are grouped under `raw files/`. `render.json`, `complete.json`, `progress.jsonl`, `resume.jsonl`, `run.lock` and the markers of earlier attempts exist only in the temporary `inprogress-*` folder; they are removed after the render is verified and before the folder is put in place. What is still needed afterwards (the export contract and the SHA-256 of every loose PNG) is in the `.ppda`. Only after damage during a resumed render can a kept `.corrupt-aNNN-K` file remain. Older result folders (loose images and `render.json`/`complete.json` directly in the folder) are still read.138
142
139
Tijdens het bouwen: `render.json` (schema 6; met `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) beschrijft projectidentiteit, bronfingerprints (SHA-256 van bodybestand en optioneel encoded equipment), camera/pivot/sample/materiaalcontract (pitches −60…+60, yaws 0…330), clipinventaris en telling. In de tijdelijke map betekent alleen `complete.json` (zelfde schema, met per-PNG SHA-256 en de namen en SHA-256 van atlassen en `.ppda`) een volledige set. Schema 5 (vóór `depth_bits`) wordt nog gelezen als 16-bit grijs. Schema 4 en ouder (o.a. het oude globale bereik 0,05–40 m) worden door de lezers geweigerd en niet gemengd; render die opnieuw. `cancelled.json`/`failed.json` markeren een gedeeltelijke run zonder compleet-marker. De bibliotheekmodule `export_set` leest een voltooide resultaatmap via de `.ppda` (oudere mappen via hun `render.json`/`complete.json`), opent uitsluitend voltooide runs met overeenkomende identiteit en geverifieerde checksums, en leest de echte RGBA8- en depthmaps (alle zes formaten, omgezet naar 16-bit diepte voor de compositor) met strikte pad-/formaatvalidatie. De map-/camera-/pixelcontracten staan in `design.md`.143
While building: `render.json` (schema 6; with `depth_near`, `depth_far`, `depth_bits`, `depth_encoding`) describes project identity, source fingerprints (SHA-256 of the body file and optionally the encoded equipment), camera/pivot/sample/material contract (pitches −60…+60, yaws 0…330), clip inventory and count. In the temporary folder only `complete.json` (same schema, with per-PNG SHA-256 and the names and SHA-256 of atlases and `.ppda`) marks a complete set. Schema 5 (before `depth_bits`) is still read as 16-bit gray. Schema 4 and older (including the old global 0.05–40 m range) are refused by the readers and not mixed; render those again. `cancelled.json`/`failed.json` mark a partial run without a complete marker. The library module `export_set` reads a finished result folder through the `.ppda` (older folders through their `render.json`/`complete.json`), opens only finished runs with matching identity and verified checksums, and reads the real RGBA8 and depth maps (all six formats, converted to 16-bit depth for the compositor) with strict path/format validation. The map/camera/pixel contracts are in `design.md`.140
144
141
### Atlassen en .ppda145
### Atlases and .ppda142
146
143
Als alle losse maps klaar en geverifieerd zijn, ook na hervatten, schrijft de render per maptype één atlas in de (tijdelijke) runmap: `<geslacht>-<settype>-<maptype>-atlas.png` (bijv. `male-sword-normal-atlas.png`). Daarna volgt `<geslacht>-<settype>-atlas.ppda`, en pas daarna `complete.json` (alleen in de tijdelijke map).147
When all loose maps are done and verified, also after resuming, the render writes one atlas per map type into the (temporary) run folder: `<sex>-<settype>-<maptype>-atlas.png` (e.g. `male-sword-normal-atlas.png`). Then follows `<sex>-<settype>-atlas.ppda`, and only after that `complete.json` (temporary folder only).144
148
145
- Een cel is exact de doelresolutie, zonder rand of schaling. Iedere rij is één (clip, frame) in bronvolgorde. De 60 kolommen zijn pitch −60→+60 en daarbinnen yaw 0→330. De vier atlassen hebben dezelfde celcoördinaten. Diffuse houdt het sRGB-kenmerk; de depth-atlas heeft byte-exact hetzelfde formaat als de losse depth-PNG's.149
- A cell is exactly the target resolution, without border or scaling. Each row is one (clip, frame) in source order. The 60 columns are pitch −60→+60 and within that yaw 0→330. The four atlases share the same cell coordinates. Diffuse keeps the sRGB chunk; the depth atlas has byte-for-byte the same format as the loose depth PNGs.146
- De atlassen worden rij voor rij uit de geverifieerde losse PNG's opgebouwd, atomair gepubliceerd en overschrijven niets. Staat er na een crash al een atlas, dan blijft die staan als hij byte-gelijk is aan de nieuwe; anders wordt hij als `.corrupt-aNNN-K` bewaard en opnieuw geschreven.150
- The atlases are built row by row from the verified loose PNGs, published atomically and overwrite nothing. If an atlas already exists after a crash, it stays if it is byte-identical to the new one; otherwise it is kept as `.corrupt-aNNN-K` and rewritten.147
- Wordt een atlas groter dan 16384 pixels in breedte of hoogte, dan weigert de render vóór de start met een duidelijke melding (verlaag fps, resolutie of animatieduur). Atlassen worden nooit gesplitst.151
- If an atlas would exceed 16384 pixels in width or height, the render refuses before starting with a clear message (lower fps, resolution or animation length). Atlases are never split.148
- `.ppda` (PaPerDollAtlas) is strikte UTF-8 JSON met `format_version` 4 (versies 2–3 worden nog gelezen; versie 2 als 16-bit grijs), `sex`, `settype`, `project` (id/naam), `identity` (body- en equipment-SHA-256, framing, pivotbron, near/far, cameraafstand, normal- en depthcodering), `fps`, `depth_near`/`depth_far` (meters bij t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` of `rgb_packed`) en `depth_bits` (8 of 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per maptype bestandsnaam, SHA-256, breedte, hoogte) en `clips` (bronindex, naam, token, `frame_count`, `duration` en per frame alle 60 hoeken als `{pitch, yaw, x, y, w, h}` in atlaspixels) en `export` (vanaf versie 4: het volledige exportcontract, voorheen `render.json`, het aantal passes en `png_sha256`, de SHA-256 per losse PNG onder `raw files/`).152
- `.ppda` (PaPerDollAtlas) is strict UTF-8 JSON with `format_version` 4 (versions 2–3 are still read; version 2 as 16-bit gray), `sex`, `settype`, `project` (id/name), `identity` (body and equipment SHA-256, framing, pivot source, near/far, camera distance, normal and depth encoding), `fps`, `depth_near`/`depth_far` (meters at t = 0 / 1), `depth_encoding` (`gray`, `rgb_bands` or `rgb_packed`) and `depth_bits` (8 or 16), `cell_width`/`cell_height`, `pitches`, `yaws`, `maps` (per map type file name, SHA-256, width, height), `clips` (source index, name, token, `frame_count`, `duration` and per frame all 60 angles as `{pitch, yaw, x, y, w, h}` in atlas pixels) and `export` (from version 4: the full export contract, formerly `render.json`, the number of passes and `png_sha256`, the SHA-256 per loose PNG under `raw files/`).149
- De bibliotheeklezer `atlas::AtlasSet` weigert onbekende velden of versies, bestandsnamen die niet de verwachte naam in dezelfde map zijn, symlinks, afwijkende atlasafmetingen of kleurtypes, celcoördinaten die niet op het raster liggen en SHA-256-fouten. `load()` decodeert de vier atlassen met dezelfde pixelregels als de losse maps; `cell()` knipt één hoek eruit. `atlas::cross_check_run` vergelijkt iedere atlascel met de losse PNG.153
- The library reader `atlas::AtlasSet` refuses unknown fields or versions, file names that are not the expected name in the same folder, symlinks, deviating atlas sizes or color types, cell coordinates off the grid and SHA-256 mismatches. `load()` decodes the four atlases with the same pixel rules as the loose maps; `cell()` crops one angle. `atlas::cross_check_run` compares every atlas cell with the loose PNG.150
154
151
### Hervatten155
### Resuming152
156
153
Een hervatte run schrijft verder in dezelfde tijdelijke `inprogress-*`-map; de resultaatmap blijft onaangeroerd tot de run klaar is. Start je een nieuwe (niet hervatte) render van hetzelfde geslacht en settype, dan worden oudere onvoltooide `inprogress-*`-mappen van dat doel eerst verwijderd, zodat alleen de nieuwste overblijft; de voltooide resultaatmap, andere doelen en een map waarin op dat moment gerenderd wordt blijven staan.157
A resumed run continues writing in the same temporary `inprogress-*` folder; the result folder is untouched until the run is done. If you start a new (not resumed) render of the same sex and set type, older unfinished `inprogress-*` folders of that target are deleted first, so only the newest remains; the finished result folder, other targets and a folder currently being rendered into are kept.154
158
155
- Elke run houdt `progress.jsonl` bij: per gepubliceerde PNG het pad en de SHA-256. `run.lock` voorkomt dat twee renders tegelijk in dezelfde run schrijven.159
- Every run keeps `progress.jsonl`: per published PNG the path and SHA-256. `run.lock` prevents two renders writing into the same run at once.156
- Hervatten kan alleen als `render.json` exact overeenkomt met wat de huidige job zou schrijven: project, geslacht/set, SHA-256 van body en equipment, target, fps, framing/pivot, camera, clips en maps. Een voltooide, vergrendelde of afwijkende run wordt geweigerd.160
- Resuming is only possible if `render.json` exactly matches what the current job would write: project, sex/set, SHA-256 of body and equipment, target, fps, framing/pivot, camera, clips and maps. A finished, locked or different run is refused.157
- Vóór het renderen wordt iedere geplande PNG gecontroleerd. Bestanden met een kloppende checksum blijven ongewijzigd en worden niet opnieuw gerenderd. Ontbrekende bestanden worden opnieuw gemaakt. Beschadigde bestanden worden hernoemd naar `<naam>.corrupt-aNNN-K` en daarna opnieuw gemaakt; ze worden niet verwijderd. Een bestaand bestand zonder journalregel (crash tussen schrijven en vastleggen) blijft staan als de opnieuw gerenderde bytes gelijk zijn. Achtergebleven `.part-*`-bestanden worden niet aangeraakt.161
- Before rendering, every planned PNG is checked. Files with a matching checksum stay unchanged and are not rendered again. Missing files are recreated. Damaged files are renamed to `<name>.corrupt-aNNN-K` and then recreated; they are not deleted. An existing file without a journal line (crash between writing and recording) stays if the re-rendered bytes are equal. Leftover `.part-*` files are not touched.158
- Na de losse maps worden de atlassen en `.ppda` geschreven (zie hierboven).162
- After the loose maps, the atlases and `.ppda` are written (see above).159
- De voortgang begint bij het werk dat al klopt; overgeslagen beelden heten *al aanwezig, checksums kloppen*. Je kunt opnieuw annuleren en later weer hervatten. Een vorige `cancelled.json`/`failed.json` wordt bewaard als `attempt-NNN-…json`, en `resume.jsonl` legt per poging vast wat geverifieerd, ontbrekend en in quarantaine gezet is. Pas als alles klopt, wordt `complete.json` met alle checksums geschreven. Daarna worden deze administratiebestanden opgeruimd (`complete.json` als laatste) en komt de map op zijn plaats. Een crash tussen opruimen en plaatsen wordt bij de volgende render hersteld: de voltooide map wordt alsnog opgeschoond en geplaatst.163
- Progress starts at the work that is already correct; skipped images are labelled *already present, checksums match*. You can cancel again and resume later. A previous `cancelled.json`/`failed.json` is kept as `attempt-NNN-…json`, and `resume.jsonl` records per attempt what was verified, missing and quarantined. Only when everything matches is `complete.json` with all checksums written. Then these bookkeeping files are cleaned up (`complete.json` last) and the folder is put in place. A crash between cleanup and placement is repaired at the next render: the finished folder is cleaned and placed.160
164
161
165
162
166
163
## 2D depth-compositor (bibliotheek)167
## 2D depth compositor (library)164
168
165
De module `compositor` combineert een body-export met nul of meer compatibele equipment-exports (zelfde project-id, geslacht, body-fingerprint, target, fps, framing, near/far en clipinventaris, gelezen uit `export_set` of uit de `.ppda`). Per pixel wint het dichtstbijzijnde geldige oppervlak (depth gray dichterbij = kleiner; epsilon `DEPTH_EPSILON_U16 = 1` depthcode; bij gelijke diepte wint de laagste laagindex — geef body eerst, daarna equipment op vaste naamvolgorde). Diffuse, normal en specular komen altijd uit **dezelfde** winnende laag. Soft alpha van die winnaar blijft behouden; verliezende lagen mixen niet mee (single-depth kan geen willekeurige doorschijnendheid reconstrueren).169
The `compositor` module combines a body export with zero or more compatible equipment exports (same project id, sex, body fingerprint, target, fps, framing, near/far and clip inventory, read from `export_set` or from the `.ppda`). Per pixel the nearest valid surface wins (depth gray closer = smaller; epsilon `DEPTH_EPSILON_U16 = 1` depth code; at equal depth the lowest layer index wins — pass the body first, then equipment in fixed name order). Diffuse, normal and specular always come from the **same** winning layer. Soft alpha of that winner is kept; losing layers do not mix in (single depth cannot reconstruct arbitrary translucency).166
170
167
`relight` / `compose_and_relight` past lineaire ambient + Lambert diffuse + Blinn-Phong specular toe. De lichtvektor staat al in view-space, gelijk aan de geëxporteerde normal maps. Er is geen randlicht: de preview toont alleen deze relight in stand Resultaat. Opgeslagen diffuse blijft elders onbelichte albedo.171
`relight` / `compose_and_relight` applies linear ambient + Lambert diffuse + Blinn-Phong specular. The light vector is already in view space, like the exported normal maps. There is no rim light: the preview shows only this relight in Result mode. Stored diffuse remains unlit albedo elsewhere.168
172
169
```rust173
```rust170
use paperdoll_renderer::compositor::{174
use paperdoll_renderer::compositor::{@@ -181,45 +185,44 @@ let layers = [181
];185
];182
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;186
let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;183
187
184
// Zelfde lagen via de consumentenvorm (.ppda + atlassen):188
// Same layers via the consumer form (.ppda + atlases):185
use paperdoll_renderer::atlas::AtlasSet;189
use paperdoll_renderer::atlas::AtlasSet;186
let body = AtlasSet::open_run(&body_run_dir)?.load()?;190
let body = AtlasSet::open_run(&body_run_dir)?.load()?;187
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;191
let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;188
```192
```189
193
194
Highres is exactly 8× on both axes; batch targets are currently **1–256 pixels per axis** (highres max 2048). Larger project settings give a clear render error, not silently reduced supersampling. At most 500000 PNGs / 16 GiB of estimated uncompressed target data per run; output requires hard links. Production renders all glTF/GLB meshes with triangle primitives (see *Multiple meshes and skins*), base color factor × 8-bit albedo textures per material, and vertex colors. An FBX body goes through the normalized scene (`fbx_scene`) in the same batch: all animation stacks × frames × 60 angles, the same fixed camera, neck pivot and fixed zoom per body, and the same four maps. Equipment hangs on the animated bones of that FBX skeleton. Static bodies render one bind-pose sample per angle (see *Static bodies*). Live placement remains a lit, simplified view.190
195
191
Highres is exact 8× beide assen; batch-targets zijn momenteel **1–256 pixels per as** (highres max 2048). Grotere projectinstellingen geven een duidelijke renderfout, geen stil verkleinde supersampling. Maximaal 500000 PNGs / 16 GiB geschatte ongecomprimeerde targetdata per run; output vereist hardlinks. Productie rendert alle glTF/GLB-meshes met driehoekprimitieven (zie *Meerdere meshes en skins*), basiskleur/8-bit albedotextures en vertexkleuren. Een FBX-lichaam loopt via de genormaliseerde scene (`fbx_scene`) door dezelfde batch: alle animatiestacks × frames × 60 hoeken, dezelfde vaste camera, nekpivot en vaste zoom per lichaam, en dezelfde vier maps. Equipment hangt op de geanimeerde botten van dat FBX-skelet. Statische lichamen renderen één bindpose-sample per hoek (zie *Statische lichamen*). Live placement blijft een belichte, vereenvoudigde weergave.196
## Open with192
193
## Openen met194
197
195
- **Windows:** start de uitgepakte `.exe` eenmaal om de editor voor het huidige account aan *Openen met* toe te voegen. Kies daarna bij een `.ppd` **Openen met → Paperdoll Renderer** (of selecteer de `.exe` via Bladeren).198
- **Windows:** run the unpacked `.exe` once to add the editor to *Open with* for the current account. Then choose **Open with → Paperdoll Renderer** on a `.ppd` (or select the `.exe` via Browse).196
- **Debian/Arch:** de pakketten installeren de `.ppd`-MIME-koppeling, desktopstarter en iconen. Kies de editor in **Openen met**.199
- **Debian/Arch:** the packages install the `.ppd` MIME association, desktop launcher and icons. Choose the editor in **Open with**.197
- **Linux AppImage/losse binary:** bewaar het bestand op zijn vaste plek en voer `"/pad/Paperdoll.AppImage" --install-desktop` uit. Hiervoor zijn `shared-mime-info` en `desktop-file-utils` nodig. Daarna staat de editor in **Openen met**. Herhaal dit na verplaatsen van de AppImage.200
- **Linux AppImage/plain binary:** keep the file in its permanent location and run `"/path/Paperdoll.AppImage" --install-desktop`. This needs `shared-mime-info` and `desktop-file-utils`. The editor then appears in **Open with**. Repeat after moving the AppImage.198
201
199
Het venster, de Linux-starter en de Windows-executable gebruiken Tims applicatie-icoon. Het venster heeft app-id `paperdoll-renderer` (gelijk aan `paperdoll-renderer.desktop` en `StartupWMClass`), zodat KDE en GNOME onder Wayland het venster aan de starter en het icoon koppelen; de app draait onder Wayland native en onder X11 via X11. Onder Wayland verschijnt het icoon pas als de desktopfile geïnstalleerd is (pakket, of bestandskoppeling vanuit de app).202
The window, the Linux launcher and the Windows executable use Tim's application icon. The window has app id `paperdoll-renderer` (equal to `paperdoll-renderer.desktop` and `StartupWMClass`), so KDE and GNOME under Wayland map the window to the launcher and icon; the app runs natively under Wayland and via X11 under X11. Under Wayland the icon only appears once the desktop file is installed (package, or file association from the app).200
203
201
## Tests204
## Tests202
205
203
`tests/robustness.rs` bewaakt dat beschadigde invoer een gewone foutmelding geeft en nooit een crash: honderden deterministische mutaties (afgekapt, bitflips, nul- en 0xFF-blokken) van de glTF/GLB-, FBX- en OBJ-lichamen, het equipment-preset en de projectmanifest, kapotte `.ppda`-bestanden, en glTF-nodes met schaal 0 of een overlopende schaal. Bij het laden van glTF worden niet-eindige posities/normalen/gewichten, verkeerde attribuutlengtes en indices buiten het vertexbereik geweigerd; een paniek in de glTF-parser wordt een laadfout.206
`tests/robustness.rs` makes sure damaged input gives an ordinary error and never a crash: hundreds of deterministic mutations (truncation, bit flips, zero and 0xFF blocks) of the glTF/GLB, FBX and OBJ bodies, the equipment preset and the project manifest, broken `.ppda` files, and glTF nodes with scale 0 or an overflowing scale. When loading glTF, non-finite positions/normals/weights, wrong attribute lengths and indices outside the vertex range are refused; a panic in the glTF parser becomes a load error.204
207
205
```bash208
```bash206
cargo fmt --check209
cargo fmt --check207
cargo check --workspace --all-targets --locked210
cargo check --workspace --all-targets --locked208
cargo clippy --workspace --all-targets --locked -- -D warnings211
cargo clippy --workspace --all-targets --locked -- -D warnings209
cargo test --workspace --locked # incl. tests/robustness.rs212
cargo test --workspace --locked # incl. tests/robustness.rs210
# Linux GPU-regressie: echte depth-occlusie, normal/depth/specular en lichtonafhankelijke albedo213
# Linux GPU regression: real depth occlusion, normal/depth/specular and light-independent albedo211
xvfb-run -a cargo run --example preview_depth_probe --locked214
xvfb-run -a cargo run --example preview_depth_probe --locked212
# Productie op echte GPU (body of equipment); controleert daarna iedere atlascel tegen de losse PNG215
# Production on a real GPU (body or equipment); then checks every atlas cell against the loose PNG213
xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4216
xvfb-run -a cargo run --example production_fixture --locked -- /tmp/prod tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb male 32 48 4214
# Hervatten op echte GPU: setup, render met crash (abort) of annulering, hervatten, export_set- en atlascontrole217
# Resuming on a real GPU: setup, render with crash (abort) or cancel, resume, export_set and atlas check215
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2218
cargo run --release --example resume_fixture --locked -- setup /tmp/resume tests/fixtures/bodies/test-body-male.glb tests/fixtures/bodies/test-body-female.glb 32 32 2216
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200219
xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200217
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male220
xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male218
cargo run --release --example resume_fixture --locked -- verify /tmp/resume221
cargo run --release --example resume_fixture --locked -- verify /tmp/resume219
# FBX op echte GPU: live view (lichaam + FBX-zwaard) en volledige productie van body- en equipmentset222
# FBX on a real GPU: live view (body + FBX sword) and full production of body and equipment set220
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4223
xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4221
# Statische lichamen op echte GPU: OBJ-man en glTF-vrouw zonder skin, live view, equipment-weigering, productie en export_set224
# Static bodies on a real GPU: OBJ male and glTF female without skin, live view, equipment refusal, production and export_set222
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4225
xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4223
```226
```224
227
225
De testlichamen komen uit `scripts/generate_test_bodies.py`. Het FBX-testbestand wordt met Blender via `scripts/convert_test_body_to_fbx.py` gemaakt. De rigide FBX-zwaarden (`sword-fbx.fbx` met ingebedde PNG, `sword-fbx-external.fbx` met PNG ernaast) komen uit `scripts/make_fbx_equipment_fixture.py` (Blender, Z-up geëxporteerd). De statische lichamen in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG en een GLB zonder skin) komen uit `scripts/generate_static_bodies.py`.228
The test bodies come from `scripts/generate_test_bodies.py`. The FBX test file is made with Blender via `scripts/convert_test_body_to_fbx.py`. The rigid FBX swords (`sword-fbx.fbx` with embedded PNG, `sword-fbx-external.fbx` with PNG next to it) come from `scripts/make_fbx_equipment_fixture.py` (Blender, exported Z-up). The static bodies in `tests/fixtures/bodies/static/` (OBJ+MTL+PNG and a GLB without skin) come from `scripts/generate_static_bodies.py`.@@ -51,6 +51,7 @@ pub struct EditorApp {
51
51
dialog_pending: bool,
52
52
/// Open "Nieuw project" form (parent folder + name), Tim t998u.3.
53
53
new_form: Option<NewProjectForm>,
54
settings_open: bool,
54
55
equipment_part: usize,
55
56
preview2d: Preview2dState,
56
57
preview2d_catalog: Vec<CatalogEntry>,
@@ -144,6 +145,7 @@ impl EditorApp {
144
145
allow_close: false,
145
146
dialog_pending: false,
146
147
new_form: None,
148
settings_open: false,
147
149
equipment_part: 0,
148
150
preview2d: Preview2dState::default(),
149
151
preview2d_catalog: Vec::new(),
@@ -167,6 +169,46 @@ impl EditorApp {
167
169
self.recent_dir = dir;
168
170
}
169
171
172
/// Switch the UI language at runtime and persist it in the config dir.
173
pub fn choose_language(&mut self, lang: crate::i18n::Lang) {
174
crate::i18n::set_lang(lang);
175
if let Some(dir) = &self.recent_dir {
176
if let Err(e) = crate::i18n::save_language(dir, lang) {
177
self.session.notice = crate::i18n::t(e);
178
self.session.notice_ok = false;
179
}
180
}
181
}
182
183
fn show_settings(&mut self, ctx: &egui::Context) {
184
if !self.settings_open {
185
return;
186
}
187
let mut open = true;
188
let mut chosen = None;
189
egui::Window::new(crate::i18n::t("Instellingen"))
190
.open(&mut open)
191
.collapsible(false)
192
.show(ctx, |ui| {
193
ui.horizontal(|ui| {
194
ui.label(crate::i18n::t("Taal"));
195
let current = crate::i18n::lang();
196
for lang in [crate::i18n::Lang::En, crate::i18n::Lang::Nl] {
197
if ui
198
.selectable_label(current == lang, lang.native_name())
199
.clicked()
200
{
201
chosen = Some(lang);
202
}
203
}
204
});
205
});
206
if let Some(lang) = chosen {
207
self.choose_language(lang);
208
}
209
self.settings_open = open;
210
}
211
170
212
pub fn recent_projects(&self) -> &[PathBuf] {
171
213
&self.recent
172
214
}
@@ -214,21 +256,21 @@ impl EditorApp {
214
256
let mut pick = false;
215
257
let mut create = false;
216
258
let mut cancel = false;
217
egui::Window::new("Nieuw project")
259
egui::Window::new(crate::i18n::t("Nieuw project"))
218
260
.collapsible(false)
219
261
.resizable(false)
220
262
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
221
263
.show(ctx, |ui| {
222
264
ui.horizontal(|ui| {
223
ui.label("Map");
265
ui.label(crate::i18n::t("Map"));
224
266
match &form.parent {
225
267
Some(p) => ui.monospace(p.display().to_string()),
226
None => ui.weak("nog niet gekozen"),
268
None => ui.weak(crate::i18n::t("nog niet gekozen")),
227
269
};
228
pick = ui.button("Map kiezen…").clicked();
270
pick = ui.button(crate::i18n::t("Map kiezen…")).clicked();
229
271
});
230
272
ui.horizontal(|ui| {
231
ui.label("Naam");
273
ui.label(crate::i18n::t("Naam"));
232
274
let edit = ui.text_edit_singleline(&mut form.name);
233
275
if edit.changed() {
234
276
form.error = None;
@@ -237,18 +279,18 @@ impl EditorApp {
237
279
});
238
280
if let Some(parent) = &form.parent {
239
281
if !form.name.trim().is_empty() {
240
ui.weak(format!(
282
ui.weak(crate::i18n::t(format!(
241
283
"Wordt aangemaakt: {}",
242
284
parent.join(form.name.trim()).display()
243
));
285
)));
244
286
}
245
287
}
246
288
if let Some(error) = &form.error {
247
ui.colored_label(egui::Color32::from_rgb(230, 90, 80), error);
289
ui.colored_label(egui::Color32::from_rgb(230, 90, 80), crate::i18n::t(error));
248
290
}
249
291
ui.horizontal(|ui| {
250
create |= ui.button("Aanmaken").clicked();
251
cancel = ui.button("Annuleren").clicked();
292
create |= ui.button(crate::i18n::t("Aanmaken")).clicked();
293
cancel = ui.button(crate::i18n::t("Annuleren")).clicked();
252
294
});
253
295
});
254
296
if pick {
@@ -818,29 +860,29 @@ impl EditorApp {
818
860
}
819
861
fn render_all_ui(&mut self, ui: &mut egui::Ui) {
820
862
if let Some(state) = &self.render_all {
821
ui.label(format!(
863
ui.label(crate::i18n::t(format!(
822
864
"Render alles: item {}/{} · {}",
823
865
state.started,
824
866
state.total,
825
867
state.current.as_deref().unwrap_or("")
826
));
868
)));
827
869
}
828
870
if let Some(summary) = &self.render_all_summary {
829
ui.label(summary.text());
871
ui.label(crate::i18n::t(summary.text()));
830
872
for label in &summary.rendered {
831
ui.label(format!("✔ {label}"));
873
ui.label(crate::i18n::t(format!("✔ {label}")));
832
874
}
833
875
for (label, reason) in &summary.skipped {
834
ui.weak(format!("overgeslagen: {label} — {reason}"));
876
ui.weak(crate::i18n::t(format!("overgeslagen: {label} — {reason}")));
835
877
}
836
878
for (label, reason) in &summary.failed {
837
879
ui.colored_label(
838
880
egui::Color32::LIGHT_RED,
839
format!("mislukt: {label} — {reason}"),
881
crate::i18n::t(format!("mislukt: {label} — {reason}")),
840
882
);
841
883
}
842
884
for label in &summary.not_started {
843
ui.weak(format!("niet gestart: {label}"));
885
ui.weak(crate::i18n::t(format!("niet gestart: {label}")));
844
886
}
845
887
}
846
888
}
@@ -898,44 +940,47 @@ impl EditorApp {
898
940
}
899
941
fn resume_ui(&mut self, ui: &mut egui::Ui) {
900
942
ui.horizontal_wrapped(|ui| {
901
ui.label("Onderbroken renders:");
943
ui.label(crate::i18n::t("Onderbroken renders:"));
902
944
if ui
903
945
.add_enabled(
904
946
!self.render_ui.running,
905
egui::Button::new("Zoek onderbroken runs"),
947
egui::Button::new(crate::i18n::t("Zoek onderbroken runs")),
906
948
)
907
.on_hover_text(
949
.on_hover_text(crate::i18n::t(
908
950
"Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.",
909
)
951
))
910
952
.clicked()
911
953
{
912
954
self.scan_resumable_runs();
913
955
}
914
956
if self.resume_scanned && self.resume_candidates.is_empty() {
915
ui.weak("geen gevonden voor deze set");
957
ui.weak(crate::i18n::t("geen gevonden voor deze set"));
916
958
}
917
959
});
918
960
if let Some(error) = &self.resume_scan_error {
919
ui.colored_label(egui::Color32::LIGHT_RED, error);
961
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));
920
962
}
921
963
let mut chosen = None;
922
964
for candidate in &self.resume_candidates {
923
965
ui.horizontal_wrapped(|ui| {
924
ui.label(format!(
966
ui.label(crate::i18n::t(format!(
925
967
"{} · {} · {} van {} PNG's vastgelegd",
926
968
candidate.name, candidate.state, candidate.journaled, candidate.planned
927
));
969
)));
928
970
match &candidate.blocked {
929
971
None => {
930
972
if ui
931
.add_enabled(!self.render_ui.running, egui::Button::new("Hervat"))
973
.add_enabled(
974
!self.render_ui.running,
975
egui::Button::new(crate::i18n::t("Hervat")),
976
)
932
977
.clicked()
933
978
{
934
979
chosen = Some(candidate.dir.clone());
935
980
}
936
981
}
937
982
Some(reason) => {
938
ui.weak(reason);
983
ui.weak(crate::i18n::t(reason));
939
984
}
940
985
}
941
986
});
@@ -1023,7 +1068,7 @@ impl EditorApp {
1023
1068
)),
1024
1069
);
1025
1070
}
1026
if self.render_ui.running && ui.button("Annuleer render").clicked() {
1071
if self.render_ui.running && ui.button(crate::i18n::t("Annuleer render")).clicked() {
1027
1072
self.cancel_render();
1028
1073
}
1029
1074
}
@@ -1063,7 +1108,7 @@ impl EditorApp {
1063
1108
camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.
1064
1109
});
1065
1110
ui.horizontal_wrapped(|ui| {
1066
ui.label("Vaste renderhoogte (m)");
1111
ui.label(crate::i18n::t("Vaste renderhoogte (m)"));
1067
1112
if ui
1068
1113
.add(
1069
1114
egui::DragValue::new(&mut height)
@@ -1074,7 +1119,7 @@ impl EditorApp {
1074
1119
{
1075
1120
camera.height_milli = (height * 1000.).round() as u32;
1076
1121
}
1077
ui.label("Centrum Y (m)");
1122
ui.label(crate::i18n::t("Centrum Y (m)"));
1078
1123
if ui
1079
1124
.add(
1080
1125
egui::DragValue::new(&mut center)
@@ -1085,7 +1130,7 @@ impl EditorApp {
1085
1130
{
1086
1131
camera.center_y_milli = (center * 1000.).round() as i32;
1087
1132
}
1088
ui.label("Pivot Y (m)");
1133
ui.label(crate::i18n::t("Pivot Y (m)"));
1089
1134
if ui
1090
1135
.add(
1091
1136
egui::DragValue::new(&mut pivot)
@@ -1098,8 +1143,10 @@ impl EditorApp {
1098
1143
}
1099
1144
if camera.pivot_y_milli.is_some()
1100
1145
&& ui
1101
.button("Auto nek")
1102
.on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")
1146
.button(crate::i18n::t("Auto nek"))
1147
.on_hover_text(crate::i18n::t(
1148
"Wis handmatige pivot; gebruik rustpose-nekhoogte",
1149
))
1103
1150
.clicked()
1104
1151
{
1105
1152
camera.pivot_y_milli = None;
@@ -1110,11 +1157,11 @@ impl EditorApp {
1110
1157
};
1111
1158
if let Some(body) = body {
1112
1159
if ui
1113
.button("Omhullende animaties")
1114
.on_hover_text(
1160
.button(crate::i18n::t("Omhullende animaties"))
1161
.on_hover_text(crate::i18n::t(
1115
1162
"Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken \
1116
1163
binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.",
1117
)
1164
))
1118
1165
.clicked()
1119
1166
{
1120
1167
if let Some((h, c)) =
@@ -1125,19 +1172,22 @@ impl EditorApp {
1125
1172
}
1126
1173
}
1127
1174
}
1128
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");
1175
ui.checkbox(
1176
&mut self.show_render_frame,
1177
crate::i18n::t("Vast renderkader tonen"),
1178
);
1129
1179
});
1130
1180
let auto_range = diagnostic
1131
1181
.as_ref()
1132
1182
.and_then(|r| r.framing.depth_range_milli)
1133
1183
.unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI);
1134
1184
ui.horizontal_wrapped(|ui| {
1135
ui.label("Dieptebereik (m)");
1185
ui.label(crate::i18n::t("Dieptebereik (m)"));
1136
1186
let mut auto = camera.depth_range_milli.is_none();
1137
1187
if ui
1138
.checkbox(&mut auto, "automatisch")
1188
.checkbox(&mut auto, crate::i18n::t("automatisch"))
1139
1189
.on_hover_text(
1140
"T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot",
1190
crate::i18n::t("T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot"),
1141
1191
)
1142
1192
.changed()
1143
1193
{
@@ -1160,12 +1210,14 @@ impl EditorApp {
1160
1210
depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)),
1161
1211
..*camera
1162
1212
});
1163
ui.weak(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)"));
1213
ui.weak(crate::i18n::t(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)")));
1164
1214
});
1165
1215
if let Some(resolved) = &diagnostic {
1166
ui.label(resolved.description());
1216
ui.label(crate::i18n::t(resolved.description()));
1167
1217
} else {
1168
ui.label("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.");
1218
ui.label(crate::i18n::t(
1219
"Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.",
1220
));
1169
1221
}
1170
1222
}
1171
1223
}
@@ -1185,26 +1237,26 @@ impl EditorApp {
1185
1237
return;
1186
1238
};
1187
1239
ui.horizontal_wrapped(|ui| {
1188
ui.label("Depth-map");
1240
ui.label(crate::i18n::t("Depth-map"));
1189
1241
egui::ComboBox::from_id_salt("depth_bits")
1190
.selected_text(format!("{bits}-bit"))
1242
.selected_text(crate::i18n::t(format!("{bits}-bit")))
1191
1243
.show_ui(ui, |ui| {
1192
ui.selectable_value(&mut bits, 8, "8-bit");
1193
ui.selectable_value(&mut bits, 16, "16-bit");
1244
ui.selectable_value(&mut bits, 8, crate::i18n::t("8-bit"));
1245
ui.selectable_value(&mut bits, 16, crate::i18n::t("16-bit"));
1194
1246
});
1195
1247
egui::ComboBox::from_id_salt("depth_encoding")
1196
.selected_text(encoding.label())
1248
.selected_text(crate::i18n::t(encoding.label()))
1197
1249
.show_ui(ui, |ui| {
1198
1250
for e in crate::camera::DepthEncoding::ALL {
1199
ui.selectable_value(&mut encoding, e, e.label());
1251
ui.selectable_value(&mut encoding, e, crate::i18n::t(e.label()));
1200
1252
}
1201
1253
});
1202
1254
if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) {
1203
ui.weak(format!("{} niveaus", fmt.levels() + 1));
1255
ui.weak(crate::i18n::t(format!("{} niveaus", fmt.levels() + 1)));
1204
1256
}
1205
1257
});
1206
1258
if encoding == crate::camera::DepthEncoding::RgbPacked {
1207
ui.weak("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).");
1259
ui.weak(crate::i18n::t("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie)."));
1208
1260
}
1209
1261
self.set_depth_format(bits, encoding);
1210
1262
}
@@ -1221,6 +1273,9 @@ impl EditorApp {
1221
1273
impl Default for EditorApp {
1222
1274
fn default() -> Self {
1223
1275
let mut app = Self::new(Box::new(SystemDialogs::new()));
1276
if let Some(dir) = crate::recent::config_dir() {
1277
crate::i18n::set_lang(crate::i18n::load_language(&dir));
1278
}
1224
1279
app.set_recent_dir(crate::recent::config_dir());
1225
1280
app
1226
1281
}
@@ -1279,7 +1334,7 @@ impl eframe::App for EditorApp {
1279
1334
} else {
1280
1335
egui::Color32::LIGHT_RED
1281
1336
};
1282
ui.colored_label(color, &self.session.notice);
1337
ui.colored_label(color, crate::i18n::t(&self.session.notice));
1283
1338
});
1284
1339
match self.session.screen {
1285
1340
Screen::Start => self.show_start(ctx),
@@ -1291,6 +1346,7 @@ impl eframe::App for EditorApp {
1291
1346
}
1292
1347
self.show_confirm(ctx);
1293
1348
self.show_new_project_form(ctx);
1349
self.show_settings(ctx);
1294
1350
if self.dialog_pending {
1295
1351
ctx.request_repaint_after(std::time::Duration::from_millis(50));
1296
1352
}
@@ -1303,20 +1359,22 @@ impl EditorApp {
1303
1359
return;
1304
1360
};
1305
1361
let mut choice = None;
1306
egui::Window::new("Wijzigingen")
1362
egui::Window::new(crate::i18n::t("Wijzigingen"))
1307
1363
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
1308
1364
.collapsible(false)
1309
1365
.resizable(false)
1310
1366
.show(ctx, |ui| {
1311
ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
1367
ui.label(crate::i18n::t(
1368
"Er zijn wijzigingen die nog niet zijn opgeslagen.",
1369
));
1312
1370
ui.horizontal(|ui| {
1313
if ui.button("Opslaan").clicked() {
1371
if ui.button(crate::i18n::t("Opslaan")).clicked() {
1314
1372
choice = Some(crate::session::Choice::Save);
1315
1373
}
1316
if ui.button("Niet opslaan").clicked() {
1374
if ui.button(crate::i18n::t("Niet opslaan")).clicked() {
1317
1375
choice = Some(crate::session::Choice::Discard);
1318
1376
}
1319
if ui.button("Annuleren").clicked() {
1377
if ui.button(crate::i18n::t("Annuleren")).clicked() {
1320
1378
choice = Some(crate::session::Choice::Cancel);
1321
1379
}
1322
1380
});
@@ -1331,32 +1389,40 @@ impl EditorApp {
1331
1389
if self.session.confirm.is_some() || self.dialog_pending {
1332
1390
ui.disable();
1333
1391
}
1334
ui.heading("Paperdoll Renderer");
1335
ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
1392
ui.heading(crate::i18n::t("Paperdoll Renderer"));
1393
ui.label(crate::i18n::t(
1394
"Open een bestaand character.ppd of kies een map voor een nieuw project.",
1395
));
1336
1396
ui.add_space(12.0);
1337
if ui.button("Project laden").clicked() {
1397
if ui.button(crate::i18n::t("Project laden")).clicked() {
1338
1398
self.begin(Intent::PickLoad);
1339
1399
}
1340
if ui.button("Nieuw project").clicked() {
1400
if ui.button(crate::i18n::t("Nieuw project")).clicked() {
1341
1401
self.begin(Intent::PickNew);
1342
1402
}
1403
if ui.button(crate::i18n::t("Instellingen")).clicked() {
1404
self.settings_open = true;
1405
}
1343
1406
if !self.recent.is_empty() {
1344
1407
ui.add_space(16.0);
1345
ui.label(egui::RichText::new("Recent geopend").strong());
1408
ui.label(egui::RichText::new(crate::i18n::t("Recent geopend")).strong());
1346
1409
let mut open = None;
1347
1410
for path in &self.recent {
1348
1411
let name = crate::recent::label(path);
1349
1412
if path.is_file() {
1350
1413
if ui
1351
.link(&name)
1414
.link(name.as_str())
1352
1415
.on_hover_text(path.display().to_string())
1353
1416
.clicked()
1354
1417
{
1355
1418
open = Some(path.clone());
1356
1419
}
1357
1420
} else {
1358
ui.add_enabled(false, egui::Label::new(format!("{name} (niet gevonden)")))
1359
.on_disabled_hover_text(path.display().to_string());
1421
ui.add_enabled(
1422
false,
1423
egui::Label::new(crate::i18n::t(format!("{name} (niet gevonden)"))),
1424
)
1425
.on_disabled_hover_text(path.display().to_string());
1360
1426
}
1361
1427
}
1362
1428
if let Some(path) = open {
@@ -1372,10 +1438,10 @@ impl EditorApp {
1372
1438
if self.session.confirm.is_some() || self.dialog_pending {
1373
1439
ui.disable();
1374
1440
}
1375
ui.heading("Body");
1441
ui.heading(crate::i18n::t("Body"));
1376
1442
if let Some(opened) = &mut self.session.opened {
1377
1443
ui.horizontal(|ui| {
1378
ui.label("Naam");
1444
ui.label(crate::i18n::t("Naam"));
1379
1445
ui.text_edit_singleline(&mut opened.document.name);
1380
1446
});
1381
1447
}
@@ -1383,11 +1449,11 @@ impl EditorApp {
1383
1449
self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
1384
1450
ui.add_space(8.0);
1385
1451
ui.horizontal(|ui| {
1386
ui.label("Breedte");
1452
ui.label(crate::i18n::t("Breedte"));
1387
1453
ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
1388
ui.label("Hoogte");
1454
ui.label(crate::i18n::t("Hoogte"));
1389
1455
ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
1390
ui.label("fps");
1456
ui.label(crate::i18n::t("fps"));
1391
1457
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
1392
1458
});
1393
1459
self.apply_render_fields();
@@ -1397,10 +1463,10 @@ impl EditorApp {
1397
1463
if ui
1398
1464
.add_enabled(
1399
1465
!self.render_ui.running && self.render_all.is_none(),
1400
egui::Button::new("Render alles"),
1466
egui::Button::new(crate::i18n::t("Render alles")),
1401
1467
)
1402
1468
.on_hover_text(
1403
"Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.",
1469
crate::i18n::t("Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/."),
1404
1470
)
1405
1471
.clicked()
1406
1472
{
@@ -1414,26 +1480,26 @@ impl EditorApp {
1414
1480
self.paint_pass(ui);
1415
1481
}
1416
1482
ui.add_space(8.0);
1417
if ui.button("2D-preview").clicked() {
1483
if ui.button(crate::i18n::t("2D-preview")).clicked() {
1418
1484
self.open_preview2d();
1419
1485
}
1420
ui.label("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.");
1486
ui.label(crate::i18n::t("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/."));
1421
1487
});
1422
1488
}
1423
1489
1424
1490
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
1425
1491
ui.horizontal(|ui| {
1426
ui.label(title);
1492
ui.label(crate::i18n::t(title));
1427
1493
let path = self
1428
1494
.session
1429
1495
.body_path(sex)
1430
1496
.unwrap_or("geen model")
1431
1497
.to_string();
1432
ui.label(path);
1433
if ui.button(pick).clicked() {
1498
ui.label(crate::i18n::t(path));
1499
if ui.button(crate::i18n::t(pick)).clicked() {
1434
1500
self.dialogs.start(DialogPurpose::Body(sex));
1435
1501
}
1436
if ui.button(open).clicked() {
1502
if ui.button(crate::i18n::t(open)).clicked() {
1437
1503
self.controls.sex = match sex {
1438
1504
Sex::Male => PreviewSex::Male,
1439
1505
Sex::Female => PreviewSex::Female,
@@ -1446,30 +1512,33 @@ impl EditorApp {
1446
1512
ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
1447
1513
Ok(model) => {
1448
1514
for mesh in &model.meshes {
1449
ui.label(format!(
1515
ui.label(crate::i18n::t(format!(
1450
1516
"{}: {} vertices, {} driehoeken",
1451
1517
mesh.name, mesh.vertex_count, mesh.triangle_count
1452
));
1518
)));
1453
1519
}
1454
1520
for material in &model.materials {
1455
ui.label(format!("materiaal {}", material.name));
1521
ui.label(crate::i18n::t(format!("materiaal {}", material.name)));
1456
1522
}
1457
1523
for texture in &model.textures {
1458
ui.label(format!(
1524
ui.label(crate::i18n::t(format!(
1459
1525
"texture {}: {}×{}",
1460
1526
texture.source, texture.width, texture.height
1461
));
1527
)));
1462
1528
}
1463
ui.label(format!("{} joints", model.joints.len()));
1529
ui.label(crate::i18n::t(format!("{} joints", model.joints.len())));
1464
1530
for clip in &model.clips {
1465
ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
1531
ui.label(crate::i18n::t(format!(
1532
"{}: {:.2} s",
1533
clip.name, clip.duration_seconds
1534
)));
1466
1535
}
1467
1536
for notice in &model.notices {
1468
ui.label(notice);
1537
ui.label(crate::i18n::t(notice));
1469
1538
}
1470
1539
}
1471
1540
Err(message) => {
1472
ui.colored_label(egui::Color32::LIGHT_RED, message);
1541
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(message));
1473
1542
}
1474
1543
});
1475
1544
}
@@ -1490,34 +1559,34 @@ impl EditorApp {
1490
1559
ui.disable();
1491
1560
}
1492
1561
ui.horizontal_wrapped(|ui| {
1493
if ui.button("Terug").clicked() {
1562
if ui.button(crate::i18n::t("Terug")).clicked() {
1494
1563
self.session.show_bodies();
1495
1564
}
1496
if ui.button("Equipment").clicked() {
1565
if ui.button(crate::i18n::t("Equipment")).clicked() {
1497
1566
self.session.show_equipment_list();
1498
1567
}
1499
1568
if ui
1500
1569
.add_enabled(
1501
1570
!self.render_ui.running,
1502
egui::Button::new("Render alle hoeken"),
1571
egui::Button::new(crate::i18n::t("Render alle hoeken")),
1503
1572
)
1504
1573
.clicked()
1505
1574
{
1506
1575
self.start_render();
1507
1576
}
1508
if ui.button("Live 3D").clicked() {
1577
if ui.button(crate::i18n::t("Live 3D")).clicked() {
1509
1578
self.render_ui.preview = None;
1510
1579
}
1511
if ui.button("2D-preview").clicked() {
1580
if ui.button(crate::i18n::t("2D-preview")).clicked() {
1512
1581
self.open_preview2d();
1513
1582
}
1514
1583
});
1515
1584
if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) {
1516
1585
ui.colored_label(
1517
1586
egui::Color32::from_rgb(230, 190, 90),
1518
format!(
1587
crate::i18n::t(format!(
1519
1588
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."
1520
),
1589
)),
1521
1590
);
1522
1591
}
1523
1592
self.framing_ui(ui, usize::from(sex == Sex::Female));
@@ -1535,7 +1604,7 @@ impl EditorApp {
1535
1604
self.viewport
1536
1605
.show_framed(ui, &mut self.controls, character, &[], framing);
1537
1606
} else {
1538
ui.label("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.");
1607
ui.label(crate::i18n::t("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave."));
1539
1608
}
1540
1609
}
1541
1610
});
@@ -1545,10 +1614,10 @@ impl EditorApp {
1545
1614
let Some(image) = &self.render_ui.preview else {
1546
1615
return;
1547
1616
};
1548
ui.label(format!(
1617
ui.label(crate::i18n::t(format!(
1549
1618
"Laatste voltooide pass: {}",
1550
1619
self.render_ui.preview_label
1551
));
1620
)));
1552
1621
if self.pass_texture.is_none()
1553
1622
|| self.pass_texture_revision != self.render_ui.preview_revision
1554
1623
{
@@ -1589,15 +1658,15 @@ impl EditorApp {
1589
1658
if self.session.confirm.is_some() || self.dialog_pending {
1590
1659
ui.disable();
1591
1660
}
1592
ui.heading(format!(
1661
ui.heading(crate::i18n::t(format!(
1593
1662
"Bodies → {} → Equipment",
1594
1663
match self.session.active_body {
1595
1664
Some(Sex::Male) => "Man",
1596
1665
Some(Sex::Female) => "Vrouw",
1597
1666
None => "Body",
1598
1667
}
1599
));
1600
if ui.button("Terug naar body").clicked() {
1668
)));
1669
if ui.button(crate::i18n::t("Terug naar body")).clicked() {
1601
1670
self.session.back();
1602
1671
}
1603
1672
let mut open = None;
@@ -1606,14 +1675,14 @@ impl EditorApp {
1606
1675
Some(model) => format!("{} {model}", item.slug),
1607
1676
None => item.slug.clone(),
1608
1677
};
1609
if ui.button(label).clicked() {
1678
if ui.button(crate::i18n::t(label)).clicked() {
1610
1679
open = Some(index);
1611
1680
}
1612
1681
}
1613
1682
if ui
1614
1683
.add_enabled(
1615
1684
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1616
egui::Button::new("Nieuw"),
1685
egui::Button::new(crate::i18n::t("Nieuw")),
1617
1686
)
1618
1687
.clicked()
1619
1688
{
@@ -1623,7 +1692,7 @@ impl EditorApp {
1623
1692
if ui
1624
1693
.add_enabled(
1625
1694
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,
1626
egui::Button::new("Preset importeren"),
1695
egui::Button::new(crate::i18n::t("Preset importeren")),
1627
1696
)
1628
1697
.clicked()
1629
1698
{
@@ -1653,45 +1722,45 @@ impl EditorApp {
1653
1722
ui.disable();
1654
1723
}
1655
1724
egui::ScrollArea::vertical().show(ui, |ui| {
1656
ui.heading("Equipment-configuratie");
1657
if ui.button("Terug naar equipment").clicked() {
1725
ui.heading(crate::i18n::t("Equipment-configuratie"));
1726
if ui.button(crate::i18n::t("Terug naar equipment")).clicked() {
1658
1727
self.session.show_equipment_list();
1659
1728
}
1660
1729
ui.horizontal(|ui| {
1661
ui.selectable_value(&mut sex, 0, "Man");
1662
ui.selectable_value(&mut sex, 1, "Vrouw");
1730
ui.selectable_value(&mut sex, 0, crate::i18n::t("Man"));
1731
ui.selectable_value(&mut sex, 1, crate::i18n::t("Vrouw"));
1663
1732
});
1664
1733
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });
1665
1734
let Some(item) = self.session.equipment.get_mut(index) else {
1666
1735
return;
1667
1736
};
1668
ui.label("Naam (kleine letters/cijfers/-/_)");
1737
ui.label(crate::i18n::t("Naam (kleine letters/cijfers/-/_)"));
1669
1738
ui.text_edit_singleline(&mut item.slug);
1670
1739
if !crate::equipment::valid_name(&item.slug) {
1671
1740
ui.colored_label(
1672
1741
egui::Color32::LIGHT_RED,
1673
"Ongeldige naam; 'body' is gereserveerd.",
1742
crate::i18n::t("Ongeldige naam; 'body' is gereserveerd."),
1674
1743
);
1675
1744
}
1676
1745
if let Some(error) = &item.error {
1677
ui.colored_label(egui::Color32::LIGHT_RED, error);
1746
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));
1678
1747
}
1679
if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() {
1748
if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX)")).clicked() {
1680
1749
self.dialogs.start(DialogPurpose::EquipmentModel(index));
1681
1750
}
1682
1751
if let Some(preset) = &mut item.preset {
1683
1752
ui.horizontal(|ui| {
1684
ui.label("Geldig voor:");
1685
ui.checkbox(&mut preset.applies[0], "Man");
1686
ui.checkbox(&mut preset.applies[1], "Vrouw");
1753
ui.label(crate::i18n::t("Geldig voor:"));
1754
ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));
1755
ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));
1687
1756
});
1688
1757
if !preset.applies[0] && !preset.applies[1] {
1689
1758
ui.colored_label(
1690
1759
egui::Color32::LIGHT_RED,
1691
"Kies minstens één geslacht.",
1760
crate::i18n::t("Kies minstens één geslacht."),
1692
1761
);
1693
1762
}
1694
ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");
1763
ui.label(crate::i18n::t("Herkomst / licentie (max. 4096 UTF-8 bytes)"));
1695
1764
ui.add(
1696
1765
egui::TextEdit::multiline(&mut preset.provenance)
1697
1766
.desired_rows(2)
@@ -1700,20 +1769,20 @@ impl EditorApp {
1700
1769
if preset.provenance.len() > 4096 {
1701
1770
ui.colored_label(
1702
1771
egui::Color32::LIGHT_RED,
1703
"Herkomst/licentie is te lang.",
1772
crate::i18n::t("Herkomst/licentie is te lang."),
1704
1773
);
1705
1774
}
1706
1775
if !preset.attachments.is_empty() {
1707
1776
self.equipment_part =
1708
1777
self.equipment_part.min(preset.attachments.len() - 1);
1709
1778
egui::ComboBox::from_id_salt("equipment-part")
1710
.selected_text(&preset.attachments[self.equipment_part].label)
1779
.selected_text(crate::i18n::t(&preset.attachments[self.equipment_part].label))
1711
1780
.show_ui(ui, |ui| {
1712
1781
for (part, item) in preset.attachments.iter().enumerate() {
1713
1782
ui.selectable_value(
1714
1783
&mut self.equipment_part,
1715
1784
part,
1716
&item.label,
1785
crate::i18n::t(&item.label),
1717
1786
);
1718
1787
}
1719
1788
});
@@ -1723,7 +1792,7 @@ impl EditorApp {
1723
1792
self.preview_female.as_ref()
1724
1793
};
1725
1794
let attachment = &mut preset.attachments[self.equipment_part];
1726
ui.label("Positie relatief aan bot (XYZ)");
1795
ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));
1727
1796
ui.horizontal(|ui| {
1728
1797
for value in &mut attachment.translation {
1729
1798
ui.add(
@@ -1733,7 +1802,7 @@ impl EditorApp {
1733
1802
);
1734
1803
}
1735
1804
});
1736
ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");
1805
ui.label(crate::i18n::t("Rotatie in graden XYZ (Rz·Ry·Rx)"));
1737
1806
ui.horizontal(|ui| {
1738
1807
for value in &mut attachment.rotation_deg {
1739
1808
ui.add(
@@ -1751,14 +1820,14 @@ impl EditorApp {
1751
1820
.shiny
1752
1821
.and_then(|i| preset.assets.get(i))
1753
1822
.map(|a| a.path.clone());
1754
ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));
1755
if ui.button("Kies shinymap (PNG)").clicked() {
1823
ui.label(crate::i18n::t(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into())));
1824
if ui.button(crate::i18n::t("Kies shinymap (PNG)")).clicked() {
1756
1825
self.dialogs.start(DialogPurpose::EquipmentShiny(
1757
1826
index,
1758
1827
self.equipment_part,
1759
1828
));
1760
1829
}
1761
let clear_shiny = ui.button("Wis shinymap").clicked();
1830
let clear_shiny = ui.button(crate::i18n::t("Wis shinymap")).clicked();
1762
1831
let selected = attachment.bones[sex].clone();
1763
1832
if clear_shiny {
1764
1833
if let Err(error) =
@@ -1767,19 +1836,19 @@ impl EditorApp {
1767
1836
item.error = Some(error);
1768
1837
}
1769
1838
}
1770
ui.label(format!(
1839
ui.label(crate::i18n::t(format!(
1771
1840
"Bot: {}",
1772
1841
selected.as_deref().unwrap_or("nog niet gekoppeld")
1773
));
1842
)));
1774
1843
if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1775
1844
ui.add_enabled_ui(false, |ui| {
1776
1845
ui.collapsing("Skeleton / botten", |ui| {
1777
ui.label("Geen botten");
1846
ui.label(crate::i18n::t("Geen botten"));
1778
1847
});
1779
1848
});
1780
1849
ui.colored_label(
1781
1850
egui::Color32::LIGHT_RED,
1782
format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam."),
1851
crate::i18n::t(format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam.")),
1783
1852
);
1784
1853
} else if let Some(body) = body {
1785
1854
if preset.rigs[sex].as_ref()
@@ -1787,7 +1856,7 @@ impl EditorApp {
1787
1856
{
1788
1857
ui.colored_label(
1789
1858
egui::Color32::LIGHT_RED,
1790
"Rigbinding ontbreekt of wijkt af; selecteer een bot.",
1859
crate::i18n::t("Rigbinding ontbreekt of wijkt af; selecteer een bot."),
1791
1860
);
1792
1861
}
1793
1862
ui.collapsing("Skeleton / botten", |ui| {
@@ -1795,17 +1864,17 @@ impl EditorApp {
1795
1864
if let Err(error) =
1796
1865
preset.bind(sex, body, self.equipment_part, joint)
1797
1866
{
1798
ui.colored_label(egui::Color32::LIGHT_RED, error);
1867
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));
1799
1868
}
1800
1869
}
1801
1870
});
1802
1871
} else {
1803
1872
ui.colored_label(
1804
1873
egui::Color32::LIGHT_RED,
1805
"Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.",
1874
crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),
1806
1875
);
1807
1876
}
1808
if ui.button("Verwijder onderdeel").clicked() {
1877
if ui.button(crate::i18n::t("Verwijder onderdeel")).clicked() {
1809
1878
if let Err(error) =
1810
1879
crate::equipment::remove_attachment(preset, self.equipment_part)
1811
1880
{
@@ -1815,7 +1884,7 @@ impl EditorApp {
1815
1884
}
1816
1885
}
1817
1886
}
1818
if ui.button("Verwijder equipmentdefinitie").clicked() {
1887
if ui.button(crate::i18n::t("Verwijder equipmentdefinitie")).clicked() {
1819
1888
self.session.delete_equipment(index);
1820
1889
}
1821
1890
});
@@ -1834,19 +1903,19 @@ impl EditorApp {
1834
1903
if ui
1835
1904
.add_enabled(
1836
1905
!self.render_ui.running && !static_body,
1837
egui::Button::new("Render equipment · alle hoeken"),
1906
egui::Button::new(crate::i18n::t("Render equipment · alle hoeken")),
1838
1907
)
1839
1908
.on_disabled_hover_text(
1840
"Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)",
1909
crate::i18n::t("Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)"),
1841
1910
)
1842
1911
.clicked()
1843
1912
{
1844
1913
self.start_render();
1845
1914
}
1846
if ui.button("Live 3D").clicked() {
1915
if ui.button(crate::i18n::t("Live 3D")).clicked() {
1847
1916
self.render_ui.preview = None;
1848
1917
}
1849
if ui.button("2D-preview").clicked() {
1918
if ui.button(crate::i18n::t("2D-preview")).clicked() {
1850
1919
self.open_preview2d();
1851
1920
}
1852
1921
});
@@ -1876,7 +1945,7 @@ impl EditorApp {
1876
1945
if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1877
1946
ui.colored_label(
1878
1947
egui::Color32::from_rgb(230, 190, 90),
1879
format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar."),
1948
crate::i18n::t(format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar.")),
1880
1949
);
1881
1950
}
1882
1951
self.viewport
@@ -2017,33 +2086,33 @@ impl EditorApp {
2017
2086
if self.session.confirm.is_some() || self.dialog_pending {
2018
2087
ui.disable();
2019
2088
}
2020
ui.heading("2D-preview");
2021
if ui.button("Terug").clicked() {
2089
ui.heading(crate::i18n::t("2D-preview"));
2090
if ui.button(crate::i18n::t("Terug")).clicked() {
2022
2091
go_back = true;
2023
2092
}
2024
2093
ui.separator();
2025
ui.label("Geslacht");
2094
ui.label(crate::i18n::t("Geslacht"));
2026
2095
ui.horizontal(|ui| {
2027
2096
if ui
2028
.selectable_label(self.preview2d.sex == Sex::Male, "Man")
2097
.selectable_label(self.preview2d.sex == Sex::Male, crate::i18n::t("Man"))
2029
2098
.clicked()
2030
2099
{
2031
2100
set_sex = Some(Sex::Male);
2032
2101
}
2033
2102
if ui
2034
.selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")
2103
.selectable_label(self.preview2d.sex == Sex::Female, crate::i18n::t("Vrouw"))
2035
2104
.clicked()
2036
2105
{
2037
2106
set_sex = Some(Sex::Female);
2038
2107
}
2039
2108
});
2040
if ui.button("Vernieuw exportsets").clicked() {
2109
if ui.button(crate::i18n::t("Vernieuw exportsets")).clicked() {
2041
2110
refresh_catalog = true;
2042
2111
}
2043
ui.label(format!(
2112
ui.label(crate::i18n::t(format!(
2044
2113
"{} voltooide exportset(s) (.ppda + atlassen)",
2045
2114
self.preview2d_catalog.len()
2046
));
2115
)));
2047
2116
if !self.preview2d_skipped.is_empty() {
2048
2117
let reasons = self
2049
2118
.preview2d_skipped
@@ -2054,27 +2123,27 @@ impl EditorApp {
2054
2123
})
2055
2124
.collect::<Vec<_>>()
2056
2125
.join("\n");
2057
ui.weak(format!(
2126
ui.weak(crate::i18n::t(format!(
2058
2127
"{} run(s) zonder geldige .ppda overgeslagen",
2059
2128
self.preview2d_skipped.len()
2060
))
2061
.on_hover_text(reasons);
2129
)))
2130
.on_hover_text(crate::i18n::t(reasons));
2062
2131
}
2063
2132
ui.separator();
2064
ui.label("Equipment (zelfde project/rig/contract)");
2133
ui.label(crate::i18n::t("Equipment (zelfde project/rig/contract)"));
2065
2134
let allowed =
2066
2135
compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);
2067
2136
if allowed.is_empty() {
2068
ui.weak("Geen compatibele equipment-export.");
2137
ui.weak(crate::i18n::t("Geen compatibele equipment-export."));
2069
2138
}
2070
2139
for set in allowed {
2071
2140
let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);
2072
if ui.checkbox(&mut checked, &set).changed() {
2141
if ui.checkbox(&mut checked, crate::i18n::t(&set)).changed() {
2073
2142
toggle_set = Some(set);
2074
2143
}
2075
2144
}
2076
2145
ui.separator();
2077
ui.label("Clip");
2146
ui.label(crate::i18n::t("Clip"));
2078
2147
let sex_token = self.preview2d.sex_token();
2079
2148
let clips: Vec<_> = self
2080
2149
.preview2d_catalog
@@ -2086,7 +2155,7 @@ impl EditorApp {
2086
2155
if clips.is_empty() {
2087
2156
ui.colored_label(
2088
2157
egui::Color32::LIGHT_RED,
2089
"Geen body-export met clips voor dit geslacht.",
2158
crate::i18n::t("Geen body-export met clips voor dit geslacht."),
2090
2159
);
2091
2160
}
2092
2161
for (token, name, frames, duration) in &clips {
@@ -2096,7 +2165,7 @@ impl EditorApp {
2096
2165
format!("{name} ({token}, {frames}f)")
2097
2166
};
2098
2167
if ui
2099
.selectable_label(self.preview2d.clip_token == *token, label)
2168
.selectable_label(self.preview2d.clip_token == *token, crate::i18n::t(label))
2100
2169
.clicked()
2101
2170
{
2102
2171
select_clip = Some((token.clone(), *frames, *duration));
@@ -2109,57 +2178,57 @@ impl EditorApp {
2109
2178
} else {
2110
2179
"Speel"
2111
2180
};
2112
if ui.button(play_label).clicked() {
2181
if ui.button(crate::i18n::t(play_label)).clicked() {
2113
2182
toggle_play = true;
2114
2183
}
2115
ui.label(format!(
2184
ui.label(crate::i18n::t(format!(
2116
2185
"frame {}/{} t={:.2}s",
2117
2186
self.preview2d.frame_index() + 1,
2118
2187
self.preview2d.clip_frames.max(1),
2119
2188
self.preview2d.elapsed_secs
2120
));
2189
)));
2121
2190
});
2122
2191
ui.horizontal(|ui| {
2123
ui.label("Snelheid");
2192
ui.label(crate::i18n::t("Snelheid"));
2124
2193
ui.add(
2125
2194
egui::TextEdit::singleline(&mut self.preview2d_speed_text)
2126
2195
.desired_width(48.0),
2127
2196
);
2128
if ui.button("Pas toe").clicked() {
2197
if ui.button(crate::i18n::t("Pas toe")).clicked() {
2129
2198
apply_speed = true;
2130
2199
}
2131
ui.label(format!("export-fps {}", self.preview2d.fps));
2200
ui.label(crate::i18n::t(format!("export-fps {}", self.preview2d.fps)));
2132
2201
});
2133
2202
ui.separator();
2134
ui.label("Weergave");
2203
ui.label(crate::i18n::t("Weergave"));
2135
2204
for mode in ViewMode::all() {
2136
2205
if ui
2137
.selectable_label(self.preview2d.view_mode == mode, mode.label_nl())
2206
.selectable_label(self.preview2d.view_mode == mode, crate::i18n::t(mode.label_nl()))
2138
2207
.clicked()
2139
2208
{
2140
2209
set_view_mode = Some(mode);
2141
2210
}
2142
2211
}
2143
ui.weak("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd).");
2212
ui.weak(crate::i18n::t("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd)."));
2144
2213
ui.separator();
2145
ui.label("Hoek (export)");
2214
ui.label(crate::i18n::t("Hoek (export)"));
2146
2215
ui.horizontal(|ui| {
2147
if ui.button("Yaw −").clicked() {
2216
if ui.button(crate::i18n::t("Yaw −")).clicked() {
2148
2217
step_yaw = -1;
2149
2218
}
2150
if ui.button("Yaw +").clicked() {
2219
if ui.button(crate::i18n::t("Yaw +")).clicked() {
2151
2220
step_yaw = 1;
2152
2221
}
2153
ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));
2222
ui.label(crate::i18n::t(format!("yaw {}°", self.preview2d.yaw_deg())));
2154
2223
});
2155
2224
ui.horizontal(|ui| {
2156
if ui.button("Pitch −").clicked() {
2225
if ui.button(crate::i18n::t("Pitch −")).clicked() {
2157
2226
step_pitch = -1;
2158
2227
}
2159
if ui.button("Pitch +").clicked() {
2228
if ui.button(crate::i18n::t("Pitch +")).clicked() {
2160
2229
step_pitch = 1;
2161
2230
}
2162
ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));
2231
ui.label(crate::i18n::t(format!("pitch {}°", self.preview2d.pitch_deg())));
2163
2232
});
2164
2233
ui.separator();
2165
2234
ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {
@@ -2167,10 +2236,10 @@ impl EditorApp {
2167
2236
let held = |r: &egui::Response| {
2168
2237
held_ctx.input(|i| crate::preview2d::raw_pointer_hold(r.rect, i))
2169
2238
};
2170
ui.label("Licht (vasthouden; °/s — alleen Resultaat)");
2239
ui.label(crate::i18n::t("Licht (vasthouden; °/s — alleen Resultaat)"));
2171
2240
ui.horizontal(|ui| {
2172
let west = ui.button("Azimut −");
2173
let east = ui.button("Azimut +");
2241
let west = ui.button(crate::i18n::t("Azimut −"));
2242
let east = ui.button(crate::i18n::t("Azimut +"));
2174
2243
if self.preview2d.view_mode.uses_light() {
2175
2244
if held(&west) {
2176
2245
hold_az = Some(HoldSign::Negative);
@@ -2183,11 +2252,11 @@ impl EditorApp {
2183
2252
release_az = true;
2184
2253
}
2185
2254
}
2186
ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));
2255
ui.label(crate::i18n::t(format!("{:.0}°", self.preview2d.light_azimuth_deg)));
2187
2256
});
2188
2257
ui.horizontal(|ui| {
2189
let down = ui.button("Elevatie −");
2190
let up = ui.button("Elevatie +");
2258
let down = ui.button(crate::i18n::t("Elevatie −"));
2259
let up = ui.button(crate::i18n::t("Elevatie +"));
2191
2260
if self.preview2d.view_mode.uses_light() {
2192
2261
if held(&down) {
2193
2262
hold_el = Some(HoldSign::Negative);
@@ -2200,10 +2269,10 @@ impl EditorApp {
2200
2269
release_el = true;
2201
2270
}
2202
2271
}
2203
ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));
2272
ui.label(crate::i18n::t(format!("{:.0}°", self.preview2d.light_elevation_deg)));
2204
2273
});
2205
2274
ui.weak(
2206
"Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.",
2275
crate::i18n::t("Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht."),
2207
2276
);
2208
2277
});
2209
2278
});
@@ -2302,11 +2371,11 @@ impl EditorApp {
2302
2371
ui.disable();
2303
2372
}
2304
2373
if let Some(diag) = &self.preview2d.diagnostic {
2305
ui.colored_label(egui::Color32::LIGHT_RED, diag);
2374
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(diag));
2306
2375
}
2307
2376
let Some(image) = &self.preview2d_image else {
2308
2377
ui.label(
2309
"Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",
2378
crate::i18n::t("Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken."),
2310
2379
);
2311
2380
return;
2312
2381
};
@@ -2350,23 +2419,32 @@ impl EditorApp {
2350
2419
ui.disable();
2351
2420
}
2352
2421
ui.horizontal(|ui| {
2353
if ui.button("Body").clicked() {
2422
if ui.button(crate::i18n::t("Body")).clicked() {
2354
2423
self.session.show_bodies();
2355
2424
}
2356
if ui.button("2D-preview").clicked() {
2425
if ui.button(crate::i18n::t("2D-preview")).clicked() {
2357
2426
self.open_preview2d();
2358
2427
}
2359
if ui.button("Opslaan").clicked() {
2428
if ui.button(crate::i18n::t("Opslaan")).clicked() {
2360
2429
self.save();
2361
2430
}
2431
if ui.button(crate::i18n::t("Instellingen")).clicked() {
2432
self.settings_open = true;
2433
}
2362
2434
if ui
2363
.add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
2435
.add_enabled(
2436
!self.render_ui.running,
2437
egui::Button::new(crate::i18n::t("Nieuw project")),
2438
)
2364
2439
.clicked()
2365
2440
{
2366
2441
self.begin(Intent::PickNew);
2367
2442
}
2368
2443
if ui
2369
.add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
2444
.add_enabled(
2445
!self.render_ui.running,
2446
egui::Button::new(crate::i18n::t("Ander project")),
2447
)
2370
2448
.clicked()
2371
2449
{
2372
2450
self.begin(Intent::PickLoad);
@@ -2407,7 +2485,7 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)
2407
2485
if ui
2408
2486
.selectable_label(
2409
2487
selected == Some(paths[joint].as_str()),
2410
format!("Koppel {label}"),
2488
crate::i18n::t(format!("Koppel {label}")),
2411
2489
)
2412
2490
.clicked()
2413
2491
{
@@ -2418,7 +2496,7 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)
2418
2496
if children.is_empty() {
2419
2497
select(ui, choice);
2420
2498
} else {
2421
egui::CollapsingHeader::new(&label)
2499
egui::CollapsingHeader::new(crate::i18n::t(&label))
2422
2500
.id_salt(index)
2423
2501
.default_open(depth < 2)
2424
2502
.show(ui, |ui| {
@@ -2814,4 +2892,17 @@ mod tests {
2814
2892
assert!(!app.render_ui.running);
2815
2893
assert!(app.render_ui.error.is_some());
2816
2894
}
2895
2896
#[test]
2897
fn language_choice_switches_at_runtime_and_is_stored() {
2898
let config = root().join("lang-config");
2899
std::fs::create_dir_all(&config).unwrap();
2900
let mut app = EditorApp::new(Box::new(ScriptedDialogs::default()));
2901
app.set_recent_dir(Some(config.clone()));
2902
app.choose_language(crate::i18n::Lang::Nl);
2903
assert_eq!(crate::i18n::load_language(&config), crate::i18n::Lang::Nl);
2904
app.choose_language(crate::i18n::Lang::En);
2905
assert_eq!(crate::i18n::load_language(&config), crate::i18n::Lang::En);
2906
assert_eq!(crate::i18n::lang(), crate::i18n::Lang::En);
2907
}
2817
2908
}
@@ -51,6 +51,7 @@ pub struct EditorApp {51
dialog_pending: bool,51
dialog_pending: bool,52
/// Open "Nieuw project" form (parent folder + name), Tim t998u.3.52
/// Open "Nieuw project" form (parent folder + name), Tim t998u.3.53
new_form: Option<NewProjectForm>,53
new_form: Option<NewProjectForm>,54
settings_open: bool,54
equipment_part: usize,55
equipment_part: usize,55
preview2d: Preview2dState,56
preview2d: Preview2dState,56
preview2d_catalog: Vec<CatalogEntry>,57
preview2d_catalog: Vec<CatalogEntry>,@@ -144,6 +145,7 @@ impl EditorApp {144
allow_close: false,145
allow_close: false,145
dialog_pending: false,146
dialog_pending: false,146
new_form: None,147
new_form: None,148
settings_open: false,147
equipment_part: 0,149
equipment_part: 0,148
preview2d: Preview2dState::default(),150
preview2d: Preview2dState::default(),149
preview2d_catalog: Vec::new(),151
preview2d_catalog: Vec::new(),@@ -167,6 +169,46 @@ impl EditorApp {167
self.recent_dir = dir;169
self.recent_dir = dir;168
}170
}169
171
172
/// Switch the UI language at runtime and persist it in the config dir.173
pub fn choose_language(&mut self, lang: crate::i18n::Lang) {174
crate::i18n::set_lang(lang);175
if let Some(dir) = &self.recent_dir {176
if let Err(e) = crate::i18n::save_language(dir, lang) {177
self.session.notice = crate::i18n::t(e);178
self.session.notice_ok = false;179
}180
}181
}182
183
fn show_settings(&mut self, ctx: &egui::Context) {184
if !self.settings_open {185
return;186
}187
let mut open = true;188
let mut chosen = None;189
egui::Window::new(crate::i18n::t("Instellingen"))190
.open(&mut open)191
.collapsible(false)192
.show(ctx, |ui| {193
ui.horizontal(|ui| {194
ui.label(crate::i18n::t("Taal"));195
let current = crate::i18n::lang();196
for lang in [crate::i18n::Lang::En, crate::i18n::Lang::Nl] {197
if ui198
.selectable_label(current == lang, lang.native_name())199
.clicked()200
{201
chosen = Some(lang);202
}203
}204
});205
});206
if let Some(lang) = chosen {207
self.choose_language(lang);208
}209
self.settings_open = open;210
}211
170
pub fn recent_projects(&self) -> &[PathBuf] {212
pub fn recent_projects(&self) -> &[PathBuf] {171
&self.recent213
&self.recent172
}214
}@@ -214,21 +256,21 @@ impl EditorApp {214
let mut pick = false;256
let mut pick = false;215
let mut create = false;257
let mut create = false;216
let mut cancel = false;258
let mut cancel = false;217
egui::Window::new("Nieuw project")259
egui::Window::new(crate::i18n::t("Nieuw project"))218
.collapsible(false)260
.collapsible(false)219
.resizable(false)261
.resizable(false)220
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])262
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])221
.show(ctx, |ui| {263
.show(ctx, |ui| {222
ui.horizontal(|ui| {264
ui.horizontal(|ui| {223
ui.label("Map");265
ui.label(crate::i18n::t("Map"));224
match &form.parent {266
match &form.parent {225
Some(p) => ui.monospace(p.display().to_string()),267
Some(p) => ui.monospace(p.display().to_string()),226
None => ui.weak("nog niet gekozen"),268
None => ui.weak(crate::i18n::t("nog niet gekozen")),227
};269
};228
pick = ui.button("Map kiezen…").clicked();270
pick = ui.button(crate::i18n::t("Map kiezen…")).clicked();229
});271
});230
ui.horizontal(|ui| {272
ui.horizontal(|ui| {231
ui.label("Naam");273
ui.label(crate::i18n::t("Naam"));232
let edit = ui.text_edit_singleline(&mut form.name);274
let edit = ui.text_edit_singleline(&mut form.name);233
if edit.changed() {275
if edit.changed() {234
form.error = None;276
form.error = None;@@ -237,18 +279,18 @@ impl EditorApp {237
});279
});238
if let Some(parent) = &form.parent {280
if let Some(parent) = &form.parent {239
if !form.name.trim().is_empty() {281
if !form.name.trim().is_empty() {240
ui.weak(format!(282
ui.weak(crate::i18n::t(format!(241
"Wordt aangemaakt: {}",283
"Wordt aangemaakt: {}",242
parent.join(form.name.trim()).display()284
parent.join(form.name.trim()).display()243
));285
)));244
}286
}245
}287
}246
if let Some(error) = &form.error {288
if let Some(error) = &form.error {247
ui.colored_label(egui::Color32::from_rgb(230, 90, 80), error);289
ui.colored_label(egui::Color32::from_rgb(230, 90, 80), crate::i18n::t(error));248
}290
}249
ui.horizontal(|ui| {291
ui.horizontal(|ui| {250
create |= ui.button("Aanmaken").clicked();292
create |= ui.button(crate::i18n::t("Aanmaken")).clicked();251
cancel = ui.button("Annuleren").clicked();293
cancel = ui.button(crate::i18n::t("Annuleren")).clicked();252
});294
});253
});295
});254
if pick {296
if pick {@@ -818,29 +860,29 @@ impl EditorApp {818
}860
}819
fn render_all_ui(&mut self, ui: &mut egui::Ui) {861
fn render_all_ui(&mut self, ui: &mut egui::Ui) {820
if let Some(state) = &self.render_all {862
if let Some(state) = &self.render_all {821
ui.label(format!(863
ui.label(crate::i18n::t(format!(822
"Render alles: item {}/{} · {}",864
"Render alles: item {}/{} · {}",823
state.started,865
state.started,824
state.total,866
state.total,825
state.current.as_deref().unwrap_or("")867
state.current.as_deref().unwrap_or("")826
));868
)));827
}869
}828
if let Some(summary) = &self.render_all_summary {870
if let Some(summary) = &self.render_all_summary {829
ui.label(summary.text());871
ui.label(crate::i18n::t(summary.text()));830
for label in &summary.rendered {872
for label in &summary.rendered {831
ui.label(format!("✔ {label}"));873
ui.label(crate::i18n::t(format!("✔ {label}")));832
}874
}833
for (label, reason) in &summary.skipped {875
for (label, reason) in &summary.skipped {834
ui.weak(format!("overgeslagen: {label} — {reason}"));876
ui.weak(crate::i18n::t(format!("overgeslagen: {label} — {reason}")));835
}877
}836
for (label, reason) in &summary.failed {878
for (label, reason) in &summary.failed {837
ui.colored_label(879
ui.colored_label(838
egui::Color32::LIGHT_RED,880
egui::Color32::LIGHT_RED,839
format!("mislukt: {label} — {reason}"),881
crate::i18n::t(format!("mislukt: {label} — {reason}")),840
);882
);841
}883
}842
for label in &summary.not_started {884
for label in &summary.not_started {843
ui.weak(format!("niet gestart: {label}"));885
ui.weak(crate::i18n::t(format!("niet gestart: {label}")));844
}886
}845
}887
}846
}888
}@@ -898,44 +940,47 @@ impl EditorApp {898
}940
}899
fn resume_ui(&mut self, ui: &mut egui::Ui) {941
fn resume_ui(&mut self, ui: &mut egui::Ui) {900
ui.horizontal_wrapped(|ui| {942
ui.horizontal_wrapped(|ui| {901
ui.label("Onderbroken renders:");943
ui.label(crate::i18n::t("Onderbroken renders:"));902
if ui944
if ui903
.add_enabled(945
.add_enabled(904
!self.render_ui.running,946
!self.render_ui.running,905
egui::Button::new("Zoek onderbroken runs"),947
egui::Button::new(crate::i18n::t("Zoek onderbroken runs")),906
)948
)907
.on_hover_text(949
.on_hover_text(crate::i18n::t(908
"Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.",950
"Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.",909
)951
))910
.clicked()952
.clicked()911
{953
{912
self.scan_resumable_runs();954
self.scan_resumable_runs();913
}955
}914
if self.resume_scanned && self.resume_candidates.is_empty() {956
if self.resume_scanned && self.resume_candidates.is_empty() {915
ui.weak("geen gevonden voor deze set");957
ui.weak(crate::i18n::t("geen gevonden voor deze set"));916
}958
}917
});959
});918
if let Some(error) = &self.resume_scan_error {960
if let Some(error) = &self.resume_scan_error {919
ui.colored_label(egui::Color32::LIGHT_RED, error);961
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));920
}962
}921
let mut chosen = None;963
let mut chosen = None;922
for candidate in &self.resume_candidates {964
for candidate in &self.resume_candidates {923
ui.horizontal_wrapped(|ui| {965
ui.horizontal_wrapped(|ui| {924
ui.label(format!(966
ui.label(crate::i18n::t(format!(925
"{} · {} · {} van {} PNG's vastgelegd",967
"{} · {} · {} van {} PNG's vastgelegd",926
candidate.name, candidate.state, candidate.journaled, candidate.planned968
candidate.name, candidate.state, candidate.journaled, candidate.planned927
));969
)));928
match &candidate.blocked {970
match &candidate.blocked {929
None => {971
None => {930
if ui972
if ui931
.add_enabled(!self.render_ui.running, egui::Button::new("Hervat"))973
.add_enabled(974
!self.render_ui.running,975
egui::Button::new(crate::i18n::t("Hervat")),976
)932
.clicked()977
.clicked()933
{978
{934
chosen = Some(candidate.dir.clone());979
chosen = Some(candidate.dir.clone());935
}980
}936
}981
}937
Some(reason) => {982
Some(reason) => {938
ui.weak(reason);983
ui.weak(crate::i18n::t(reason));939
}984
}940
}985
}941
});986
});@@ -1023,7 +1068,7 @@ impl EditorApp {1023
)),1068
)),1024
);1069
);1025
}1070
}1026
if self.render_ui.running && ui.button("Annuleer render").clicked() {1071
if self.render_ui.running && ui.button(crate::i18n::t("Annuleer render")).clicked() {1027
self.cancel_render();1072
self.cancel_render();1028
}1073
}1029
}1074
}@@ -1063,7 +1108,7 @@ impl EditorApp {1063
camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.1108
camera.pivot_y_milli.unwrap_or(camera.center_y_milli) as f32 / 1000.1064
});1109
});1065
ui.horizontal_wrapped(|ui| {1110
ui.horizontal_wrapped(|ui| {1066
ui.label("Vaste renderhoogte (m)");1111
ui.label(crate::i18n::t("Vaste renderhoogte (m)"));1067
if ui1112
if ui1068
.add(1113
.add(1069
egui::DragValue::new(&mut height)1114
egui::DragValue::new(&mut height)@@ -1074,7 +1119,7 @@ impl EditorApp {1074
{1119
{1075
camera.height_milli = (height * 1000.).round() as u32;1120
camera.height_milli = (height * 1000.).round() as u32;1076
}1121
}1077
ui.label("Centrum Y (m)");1122
ui.label(crate::i18n::t("Centrum Y (m)"));1078
if ui1123
if ui1079
.add(1124
.add(1080
egui::DragValue::new(&mut center)1125
egui::DragValue::new(&mut center)@@ -1085,7 +1130,7 @@ impl EditorApp {1085
{1130
{1086
camera.center_y_milli = (center * 1000.).round() as i32;1131
camera.center_y_milli = (center * 1000.).round() as i32;1087
}1132
}1088
ui.label("Pivot Y (m)");1133
ui.label(crate::i18n::t("Pivot Y (m)"));1089
if ui1134
if ui1090
.add(1135
.add(1091
egui::DragValue::new(&mut pivot)1136
egui::DragValue::new(&mut pivot)@@ -1098,8 +1143,10 @@ impl EditorApp {1098
}1143
}1099
if camera.pivot_y_milli.is_some()1144
if camera.pivot_y_milli.is_some()1100
&& ui1145
&& ui1101
.button("Auto nek")1146
.button(crate::i18n::t("Auto nek"))1102
.on_hover_text("Wis handmatige pivot; gebruik rustpose-nekhoogte")1147
.on_hover_text(crate::i18n::t(1148
"Wis handmatige pivot; gebruik rustpose-nekhoogte",1149
))1103
.clicked()1150
.clicked()1104
{1151
{1105
camera.pivot_y_milli = None;1152
camera.pivot_y_milli = None;@@ -1110,11 +1157,11 @@ impl EditorApp {1110
};1157
};1111
if let Some(body) = body {1158
if let Some(body) = body {1112
if ui1159
if ui1113
.button("Omhullende animaties")1160
.button(crate::i18n::t("Omhullende animaties"))1114
.on_hover_text(1161
.on_hover_text(crate::i18n::t(1115
"Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken \1162
"Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken \1116
binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.",1163
binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.",1117
)1164
))1118
.clicked()1165
.clicked()1119
{1166
{1120
if let Some((h, c)) =1167
if let Some((h, c)) =@@ -1125,19 +1172,22 @@ impl EditorApp {1125
}1172
}1126
}1173
}1127
}1174
}1128
ui.checkbox(&mut self.show_render_frame, "Vast renderkader tonen");1175
ui.checkbox(1176
&mut self.show_render_frame,1177
crate::i18n::t("Vast renderkader tonen"),1178
);1129
});1179
});1130
let auto_range = diagnostic1180
let auto_range = diagnostic1131
.as_ref()1181
.as_ref()1132
.and_then(|r| r.framing.depth_range_milli)1182
.and_then(|r| r.framing.depth_range_milli)1133
.unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI);1183
.unwrap_or(crate::camera::MIN_DEPTH_RANGE_MILLI);1134
ui.horizontal_wrapped(|ui| {1184
ui.horizontal_wrapped(|ui| {1135
ui.label("Dieptebereik (m)");1185
ui.label(crate::i18n::t("Dieptebereik (m)"));1136
let mut auto = camera.depth_range_milli.is_none();1186
let mut auto = camera.depth_range_milli.is_none();1137
if ui1187
if ui1138
.checkbox(&mut auto, "automatisch")1188
.checkbox(&mut auto, crate::i18n::t("automatisch"))1139
.on_hover_text(1189
.on_hover_text(1140
"T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot",1190
crate::i18n::t("T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot"),1141
)1191
)1142
.changed()1192
.changed()1143
{1193
{@@ -1160,12 +1210,14 @@ impl EditorApp {1160
depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)),1210
depth_range_milli: Some(camera.depth_range_milli.unwrap_or(auto_range)),1161
..*camera1211
..*camera1162
});1212
});1163
ui.weak(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)"));1213
ui.weak(crate::i18n::t(format!("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)")));1164
});1214
});1165
if let Some(resolved) = &diagnostic {1215
if let Some(resolved) = &diagnostic {1166
ui.label(resolved.description());1216
ui.label(crate::i18n::t(resolved.description()));1167
} else {1217
} else {1168
ui.label("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.");1218
ui.label(crate::i18n::t(1219
"Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.",1220
));1169
}1221
}1170
}1222
}1171
}1223
}@@ -1185,26 +1237,26 @@ impl EditorApp {1185
return;1237
return;1186
};1238
};1187
ui.horizontal_wrapped(|ui| {1239
ui.horizontal_wrapped(|ui| {1188
ui.label("Depth-map");1240
ui.label(crate::i18n::t("Depth-map"));1189
egui::ComboBox::from_id_salt("depth_bits")1241
egui::ComboBox::from_id_salt("depth_bits")1190
.selected_text(format!("{bits}-bit"))1242
.selected_text(crate::i18n::t(format!("{bits}-bit")))1191
.show_ui(ui, |ui| {1243
.show_ui(ui, |ui| {1192
ui.selectable_value(&mut bits, 8, "8-bit");1244
ui.selectable_value(&mut bits, 8, crate::i18n::t("8-bit"));1193
ui.selectable_value(&mut bits, 16, "16-bit");1245
ui.selectable_value(&mut bits, 16, crate::i18n::t("16-bit"));1194
});1246
});1195
egui::ComboBox::from_id_salt("depth_encoding")1247
egui::ComboBox::from_id_salt("depth_encoding")1196
.selected_text(encoding.label())1248
.selected_text(crate::i18n::t(encoding.label()))1197
.show_ui(ui, |ui| {1249
.show_ui(ui, |ui| {1198
for e in crate::camera::DepthEncoding::ALL {1250
for e in crate::camera::DepthEncoding::ALL {1199
ui.selectable_value(&mut encoding, e, e.label());1251
ui.selectable_value(&mut encoding, e, crate::i18n::t(e.label()));1200
}1252
}1201
});1253
});1202
if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) {1254
if let Ok(fmt) = crate::camera::DepthFormat::new(bits, encoding) {1203
ui.weak(format!("{} niveaus", fmt.levels() + 1));1255
ui.weak(crate::i18n::t(format!("{} niveaus", fmt.levels() + 1)));1204
}1256
}1205
});1257
});1206
if encoding == crate::camera::DepthEncoding::RgbPacked {1258
if encoding == crate::camera::DepthEncoding::RgbPacked {1207
ui.weak("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).");1259
ui.weak(crate::i18n::t("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie)."));1208
}1260
}1209
self.set_depth_format(bits, encoding);1261
self.set_depth_format(bits, encoding);1210
}1262
}@@ -1221,6 +1273,9 @@ impl EditorApp {1221
impl Default for EditorApp {1273
impl Default for EditorApp {1222
fn default() -> Self {1274
fn default() -> Self {1223
let mut app = Self::new(Box::new(SystemDialogs::new()));1275
let mut app = Self::new(Box::new(SystemDialogs::new()));1276
if let Some(dir) = crate::recent::config_dir() {1277
crate::i18n::set_lang(crate::i18n::load_language(&dir));1278
}1224
app.set_recent_dir(crate::recent::config_dir());1279
app.set_recent_dir(crate::recent::config_dir());1225
app1280
app1226
}1281
}@@ -1279,7 +1334,7 @@ impl eframe::App for EditorApp {1279
} else {1334
} else {1280
egui::Color32::LIGHT_RED1335
egui::Color32::LIGHT_RED1281
};1336
};1282
ui.colored_label(color, &self.session.notice);1337
ui.colored_label(color, crate::i18n::t(&self.session.notice));1283
});1338
});1284
match self.session.screen {1339
match self.session.screen {1285
Screen::Start => self.show_start(ctx),1340
Screen::Start => self.show_start(ctx),@@ -1291,6 +1346,7 @@ impl eframe::App for EditorApp {1291
}1346
}1292
self.show_confirm(ctx);1347
self.show_confirm(ctx);1293
self.show_new_project_form(ctx);1348
self.show_new_project_form(ctx);1349
self.show_settings(ctx);1294
if self.dialog_pending {1350
if self.dialog_pending {1295
ctx.request_repaint_after(std::time::Duration::from_millis(50));1351
ctx.request_repaint_after(std::time::Duration::from_millis(50));1296
}1352
}@@ -1303,20 +1359,22 @@ impl EditorApp {1303
return;1359
return;1304
};1360
};1305
let mut choice = None;1361
let mut choice = None;1306
egui::Window::new("Wijzigingen")1362
egui::Window::new(crate::i18n::t("Wijzigingen"))1307
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)1363
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)1308
.collapsible(false)1364
.collapsible(false)1309
.resizable(false)1365
.resizable(false)1310
.show(ctx, |ui| {1366
.show(ctx, |ui| {1311
ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");1367
ui.label(crate::i18n::t(1368
"Er zijn wijzigingen die nog niet zijn opgeslagen.",1369
));1312
ui.horizontal(|ui| {1370
ui.horizontal(|ui| {1313
if ui.button("Opslaan").clicked() {1371
if ui.button(crate::i18n::t("Opslaan")).clicked() {1314
choice = Some(crate::session::Choice::Save);1372
choice = Some(crate::session::Choice::Save);1315
}1373
}1316
if ui.button("Niet opslaan").clicked() {1374
if ui.button(crate::i18n::t("Niet opslaan")).clicked() {1317
choice = Some(crate::session::Choice::Discard);1375
choice = Some(crate::session::Choice::Discard);1318
}1376
}1319
if ui.button("Annuleren").clicked() {1377
if ui.button(crate::i18n::t("Annuleren")).clicked() {1320
choice = Some(crate::session::Choice::Cancel);1378
choice = Some(crate::session::Choice::Cancel);1321
}1379
}1322
});1380
});@@ -1331,32 +1389,40 @@ impl EditorApp {1331
if self.session.confirm.is_some() || self.dialog_pending {1389
if self.session.confirm.is_some() || self.dialog_pending {1332
ui.disable();1390
ui.disable();1333
}1391
}1334
ui.heading("Paperdoll Renderer");1392
ui.heading(crate::i18n::t("Paperdoll Renderer"));1335
ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");1393
ui.label(crate::i18n::t(1394
"Open een bestaand character.ppd of kies een map voor een nieuw project.",1395
));1336
ui.add_space(12.0);1396
ui.add_space(12.0);1337
if ui.button("Project laden").clicked() {1397
if ui.button(crate::i18n::t("Project laden")).clicked() {1338
self.begin(Intent::PickLoad);1398
self.begin(Intent::PickLoad);1339
}1399
}1340
if ui.button("Nieuw project").clicked() {1400
if ui.button(crate::i18n::t("Nieuw project")).clicked() {1341
self.begin(Intent::PickNew);1401
self.begin(Intent::PickNew);1342
}1402
}1403
if ui.button(crate::i18n::t("Instellingen")).clicked() {1404
self.settings_open = true;1405
}1343
if !self.recent.is_empty() {1406
if !self.recent.is_empty() {1344
ui.add_space(16.0);1407
ui.add_space(16.0);1345
ui.label(egui::RichText::new("Recent geopend").strong());1408
ui.label(egui::RichText::new(crate::i18n::t("Recent geopend")).strong());1346
let mut open = None;1409
let mut open = None;1347
for path in &self.recent {1410
for path in &self.recent {1348
let name = crate::recent::label(path);1411
let name = crate::recent::label(path);1349
if path.is_file() {1412
if path.is_file() {1350
if ui1413
if ui1351
.link(&name)1414
.link(name.as_str())1352
.on_hover_text(path.display().to_string())1415
.on_hover_text(path.display().to_string())1353
.clicked()1416
.clicked()1354
{1417
{1355
open = Some(path.clone());1418
open = Some(path.clone());1356
}1419
}1357
} else {1420
} else {1358
ui.add_enabled(false, egui::Label::new(format!("{name} (niet gevonden)")))1421
ui.add_enabled(1359
.on_disabled_hover_text(path.display().to_string());1422
false,1423
egui::Label::new(crate::i18n::t(format!("{name} (niet gevonden)"))),1424
)1425
.on_disabled_hover_text(path.display().to_string());1360
}1426
}1361
}1427
}1362
if let Some(path) = open {1428
if let Some(path) = open {@@ -1372,10 +1438,10 @@ impl EditorApp {1372
if self.session.confirm.is_some() || self.dialog_pending {1438
if self.session.confirm.is_some() || self.dialog_pending {1373
ui.disable();1439
ui.disable();1374
}1440
}1375
ui.heading("Body");1441
ui.heading(crate::i18n::t("Body"));1376
if let Some(opened) = &mut self.session.opened {1442
if let Some(opened) = &mut self.session.opened {1377
ui.horizontal(|ui| {1443
ui.horizontal(|ui| {1378
ui.label("Naam");1444
ui.label(crate::i18n::t("Naam"));1379
ui.text_edit_singleline(&mut opened.document.name);1445
ui.text_edit_singleline(&mut opened.document.name);1380
});1446
});1381
}1447
}@@ -1383,11 +1449,11 @@ impl EditorApp {1383
self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");1449
self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");1384
ui.add_space(8.0);1450
ui.add_space(8.0);1385
ui.horizontal(|ui| {1451
ui.horizontal(|ui| {1386
ui.label("Breedte");1452
ui.label(crate::i18n::t("Breedte"));1387
ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));1453
ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));1388
ui.label("Hoogte");1454
ui.label(crate::i18n::t("Hoogte"));1389
ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));1455
ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));1390
ui.label("fps");1456
ui.label(crate::i18n::t("fps"));1391
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));1457
ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));1392
});1458
});1393
self.apply_render_fields();1459
self.apply_render_fields();@@ -1397,10 +1463,10 @@ impl EditorApp {1397
if ui1463
if ui1398
.add_enabled(1464
.add_enabled(1399
!self.render_ui.running && self.render_all.is_none(),1465
!self.render_ui.running && self.render_all.is_none(),1400
egui::Button::new("Render alles"),1466
egui::Button::new(crate::i18n::t("Render alles")),1401
)1467
)1402
.on_hover_text(1468
.on_hover_text(1403
"Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.",1469
crate::i18n::t("Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/."),1404
)1470
)1405
.clicked()1471
.clicked()1406
{1472
{@@ -1414,26 +1480,26 @@ impl EditorApp {1414
self.paint_pass(ui);1480
self.paint_pass(ui);1415
}1481
}1416
ui.add_space(8.0);1482
ui.add_space(8.0);1417
if ui.button("2D-preview").clicked() {1483
if ui.button(crate::i18n::t("2D-preview")).clicked() {1418
self.open_preview2d();1484
self.open_preview2d();1419
}1485
}1420
ui.label("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.");1486
ui.label(crate::i18n::t("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/."));1421
});1487
});1422
}1488
}1423
1489
1424
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {1490
fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {1425
ui.horizontal(|ui| {1491
ui.horizontal(|ui| {1426
ui.label(title);1492
ui.label(crate::i18n::t(title));1427
let path = self1493
let path = self1428
.session1494
.session1429
.body_path(sex)1495
.body_path(sex)1430
.unwrap_or("geen model")1496
.unwrap_or("geen model")1431
.to_string();1497
.to_string();1432
ui.label(path);1498
ui.label(crate::i18n::t(path));1433
if ui.button(pick).clicked() {1499
if ui.button(crate::i18n::t(pick)).clicked() {1434
self.dialogs.start(DialogPurpose::Body(sex));1500
self.dialogs.start(DialogPurpose::Body(sex));1435
}1501
}1436
if ui.button(open).clicked() {1502
if ui.button(crate::i18n::t(open)).clicked() {1437
self.controls.sex = match sex {1503
self.controls.sex = match sex {1438
Sex::Male => PreviewSex::Male,1504
Sex::Male => PreviewSex::Male,1439
Sex::Female => PreviewSex::Female,1505
Sex::Female => PreviewSex::Female,@@ -1446,30 +1512,33 @@ impl EditorApp {1446
ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {1512
ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {1447
Ok(model) => {1513
Ok(model) => {1448
for mesh in &model.meshes {1514
for mesh in &model.meshes {1449
ui.label(format!(1515
ui.label(crate::i18n::t(format!(1450
"{}: {} vertices, {} driehoeken",1516
"{}: {} vertices, {} driehoeken",1451
mesh.name, mesh.vertex_count, mesh.triangle_count1517
mesh.name, mesh.vertex_count, mesh.triangle_count1452
));1518
)));1453
}1519
}1454
for material in &model.materials {1520
for material in &model.materials {1455
ui.label(format!("materiaal {}", material.name));1521
ui.label(crate::i18n::t(format!("materiaal {}", material.name)));1456
}1522
}1457
for texture in &model.textures {1523
for texture in &model.textures {1458
ui.label(format!(1524
ui.label(crate::i18n::t(format!(1459
"texture {}: {}×{}",1525
"texture {}: {}×{}",1460
texture.source, texture.width, texture.height1526
texture.source, texture.width, texture.height1461
));1527
)));1462
}1528
}1463
ui.label(format!("{} joints", model.joints.len()));1529
ui.label(crate::i18n::t(format!("{} joints", model.joints.len())));1464
for clip in &model.clips {1530
for clip in &model.clips {1465
ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));1531
ui.label(crate::i18n::t(format!(1532
"{}: {:.2} s",1533
clip.name, clip.duration_seconds1534
)));1466
}1535
}1467
for notice in &model.notices {1536
for notice in &model.notices {1468
ui.label(notice);1537
ui.label(crate::i18n::t(notice));1469
}1538
}1470
}1539
}1471
Err(message) => {1540
Err(message) => {1472
ui.colored_label(egui::Color32::LIGHT_RED, message);1541
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(message));1473
}1542
}1474
});1543
});1475
}1544
}@@ -1490,34 +1559,34 @@ impl EditorApp {1490
ui.disable();1559
ui.disable();1491
}1560
}1492
ui.horizontal_wrapped(|ui| {1561
ui.horizontal_wrapped(|ui| {1493
if ui.button("Terug").clicked() {1562
if ui.button(crate::i18n::t("Terug")).clicked() {1494
self.session.show_bodies();1563
self.session.show_bodies();1495
}1564
}1496
if ui.button("Equipment").clicked() {1565
if ui.button(crate::i18n::t("Equipment")).clicked() {1497
self.session.show_equipment_list();1566
self.session.show_equipment_list();1498
}1567
}1499
if ui1568
if ui1500
.add_enabled(1569
.add_enabled(1501
!self.render_ui.running,1570
!self.render_ui.running,1502
egui::Button::new("Render alle hoeken"),1571
egui::Button::new(crate::i18n::t("Render alle hoeken")),1503
)1572
)1504
.clicked()1573
.clicked()1505
{1574
{1506
self.start_render();1575
self.start_render();1507
}1576
}1508
if ui.button("Live 3D").clicked() {1577
if ui.button(crate::i18n::t("Live 3D")).clicked() {1509
self.render_ui.preview = None;1578
self.render_ui.preview = None;1510
}1579
}1511
if ui.button("2D-preview").clicked() {1580
if ui.button(crate::i18n::t("2D-preview")).clicked() {1512
self.open_preview2d();1581
self.open_preview2d();1513
}1582
}1514
});1583
});1515
if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) {1584
if let Some(reason) = self.character().and_then(PreviewCharacter::static_reason) {1516
ui.colored_label(1585
ui.colored_label(1517
egui::Color32::from_rgb(230, 190, 90),1586
egui::Color32::from_rgb(230, 190, 90),1518
format!(1587
crate::i18n::t(format!(1519
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."1588
"{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken."1520
),1589
)),1521
);1590
);1522
}1591
}1523
self.framing_ui(ui, usize::from(sex == Sex::Female));1592
self.framing_ui(ui, usize::from(sex == Sex::Female));@@ -1535,7 +1604,7 @@ impl EditorApp {1535
self.viewport1604
self.viewport1536
.show_framed(ui, &mut self.controls, character, &[], framing);1605
.show_framed(ui, &mut self.controls, character, &[], framing);1537
} else {1606
} else {1538
ui.label("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.");1607
ui.label(crate::i18n::t("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave."));1539
}1608
}1540
}1609
}1541
});1610
});@@ -1545,10 +1614,10 @@ impl EditorApp {1545
let Some(image) = &self.render_ui.preview else {1614
let Some(image) = &self.render_ui.preview else {1546
return;1615
return;1547
};1616
};1548
ui.label(format!(1617
ui.label(crate::i18n::t(format!(1549
"Laatste voltooide pass: {}",1618
"Laatste voltooide pass: {}",1550
self.render_ui.preview_label1619
self.render_ui.preview_label1551
));1620
)));1552
if self.pass_texture.is_none()1621
if self.pass_texture.is_none()1553
|| self.pass_texture_revision != self.render_ui.preview_revision1622
|| self.pass_texture_revision != self.render_ui.preview_revision1554
{1623
{@@ -1589,15 +1658,15 @@ impl EditorApp {1589
if self.session.confirm.is_some() || self.dialog_pending {1658
if self.session.confirm.is_some() || self.dialog_pending {1590
ui.disable();1659
ui.disable();1591
}1660
}1592
ui.heading(format!(1661
ui.heading(crate::i18n::t(format!(1593
"Bodies → {} → Equipment",1662
"Bodies → {} → Equipment",1594
match self.session.active_body {1663
match self.session.active_body {1595
Some(Sex::Male) => "Man",1664
Some(Sex::Male) => "Man",1596
Some(Sex::Female) => "Vrouw",1665
Some(Sex::Female) => "Vrouw",1597
None => "Body",1666
None => "Body",1598
}1667
}1599
));1668
)));1600
if ui.button("Terug naar body").clicked() {1669
if ui.button(crate::i18n::t("Terug naar body")).clicked() {1601
self.session.back();1670
self.session.back();1602
}1671
}1603
let mut open = None;1672
let mut open = None;@@ -1606,14 +1675,14 @@ impl EditorApp {1606
Some(model) => format!("{} {model}", item.slug),1675
Some(model) => format!("{} {model}", item.slug),1607
None => item.slug.clone(),1676
None => item.slug.clone(),1608
};1677
};1609
if ui.button(label).clicked() {1678
if ui.button(crate::i18n::t(label)).clicked() {1610
open = Some(index);1679
open = Some(index);1611
}1680
}1612
}1681
}1613
if ui1682
if ui1614
.add_enabled(1683
.add_enabled(1615
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,1684
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,1616
egui::Button::new("Nieuw"),1685
egui::Button::new(crate::i18n::t("Nieuw")),1617
)1686
)1618
.clicked()1687
.clicked()1619
{1688
{@@ -1623,7 +1692,7 @@ impl EditorApp {1623
if ui1692
if ui1624
.add_enabled(1693
.add_enabled(1625
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,1694
self.session.equipment.len() < crate::project::MAX_PROJECT_PRESETS,1626
egui::Button::new("Preset importeren"),1695
egui::Button::new(crate::i18n::t("Preset importeren")),1627
)1696
)1628
.clicked()1697
.clicked()1629
{1698
{@@ -1653,45 +1722,45 @@ impl EditorApp {1653
ui.disable();1722
ui.disable();1654
}1723
}1655
egui::ScrollArea::vertical().show(ui, |ui| {1724
egui::ScrollArea::vertical().show(ui, |ui| {1656
ui.heading("Equipment-configuratie");1725
ui.heading(crate::i18n::t("Equipment-configuratie"));1657
if ui.button("Terug naar equipment").clicked() {1726
if ui.button(crate::i18n::t("Terug naar equipment")).clicked() {1658
self.session.show_equipment_list();1727
self.session.show_equipment_list();1659
}1728
}1660
ui.horizontal(|ui| {1729
ui.horizontal(|ui| {1661
ui.selectable_value(&mut sex, 0, "Man");1730
ui.selectable_value(&mut sex, 0, crate::i18n::t("Man"));1662
ui.selectable_value(&mut sex, 1, "Vrouw");1731
ui.selectable_value(&mut sex, 1, crate::i18n::t("Vrouw"));1663
});1732
});1664
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });1733
self.session.active_body = Some(if sex == 0 { Sex::Male } else { Sex::Female });1665
let Some(item) = self.session.equipment.get_mut(index) else {1734
let Some(item) = self.session.equipment.get_mut(index) else {1666
return;1735
return;1667
};1736
};1668
ui.label("Naam (kleine letters/cijfers/-/_)");1737
ui.label(crate::i18n::t("Naam (kleine letters/cijfers/-/_)"));1669
ui.text_edit_singleline(&mut item.slug);1738
ui.text_edit_singleline(&mut item.slug);1670
if !crate::equipment::valid_name(&item.slug) {1739
if !crate::equipment::valid_name(&item.slug) {1671
ui.colored_label(1740
ui.colored_label(1672
egui::Color32::LIGHT_RED,1741
egui::Color32::LIGHT_RED,1673
"Ongeldige naam; 'body' is gereserveerd.",1742
crate::i18n::t("Ongeldige naam; 'body' is gereserveerd."),1674
);1743
);1675
}1744
}1676
if let Some(error) = &item.error {1745
if let Some(error) = &item.error {1677
ui.colored_label(egui::Color32::LIGHT_RED, error);1746
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));1678
}1747
}1679
if ui.button("Onderdeel toevoegen (OBJ/FBX)").clicked() {1748
if ui.button(crate::i18n::t("Onderdeel toevoegen (OBJ/FBX)")).clicked() {1680
self.dialogs.start(DialogPurpose::EquipmentModel(index));1749
self.dialogs.start(DialogPurpose::EquipmentModel(index));1681
}1750
}1682
if let Some(preset) = &mut item.preset {1751
if let Some(preset) = &mut item.preset {1683
ui.horizontal(|ui| {1752
ui.horizontal(|ui| {1684
ui.label("Geldig voor:");1753
ui.label(crate::i18n::t("Geldig voor:"));1685
ui.checkbox(&mut preset.applies[0], "Man");1754
ui.checkbox(&mut preset.applies[0], crate::i18n::t("Man"));1686
ui.checkbox(&mut preset.applies[1], "Vrouw");1755
ui.checkbox(&mut preset.applies[1], crate::i18n::t("Vrouw"));1687
});1756
});1688
if !preset.applies[0] && !preset.applies[1] {1757
if !preset.applies[0] && !preset.applies[1] {1689
ui.colored_label(1758
ui.colored_label(1690
egui::Color32::LIGHT_RED,1759
egui::Color32::LIGHT_RED,1691
"Kies minstens één geslacht.",1760
crate::i18n::t("Kies minstens één geslacht."),1692
);1761
);1693
}1762
}1694
ui.label("Herkomst / licentie (max. 4096 UTF-8 bytes)");1763
ui.label(crate::i18n::t("Herkomst / licentie (max. 4096 UTF-8 bytes)"));1695
ui.add(1764
ui.add(1696
egui::TextEdit::multiline(&mut preset.provenance)1765
egui::TextEdit::multiline(&mut preset.provenance)1697
.desired_rows(2)1766
.desired_rows(2)@@ -1700,20 +1769,20 @@ impl EditorApp {1700
if preset.provenance.len() > 4096 {1769
if preset.provenance.len() > 4096 {1701
ui.colored_label(1770
ui.colored_label(1702
egui::Color32::LIGHT_RED,1771
egui::Color32::LIGHT_RED,1703
"Herkomst/licentie is te lang.",1772
crate::i18n::t("Herkomst/licentie is te lang."),1704
);1773
);1705
}1774
}1706
if !preset.attachments.is_empty() {1775
if !preset.attachments.is_empty() {1707
self.equipment_part =1776
self.equipment_part =1708
self.equipment_part.min(preset.attachments.len() - 1);1777
self.equipment_part.min(preset.attachments.len() - 1);1709
egui::ComboBox::from_id_salt("equipment-part")1778
egui::ComboBox::from_id_salt("equipment-part")1710
.selected_text(&preset.attachments[self.equipment_part].label)1779
.selected_text(crate::i18n::t(&preset.attachments[self.equipment_part].label))1711
.show_ui(ui, |ui| {1780
.show_ui(ui, |ui| {1712
for (part, item) in preset.attachments.iter().enumerate() {1781
for (part, item) in preset.attachments.iter().enumerate() {1713
ui.selectable_value(1782
ui.selectable_value(1714
&mut self.equipment_part,1783
&mut self.equipment_part,1715
part,1784
part,1716
&item.label,1785
crate::i18n::t(&item.label),1717
);1786
);1718
}1787
}1719
});1788
});@@ -1723,7 +1792,7 @@ impl EditorApp {1723
self.preview_female.as_ref()1792
self.preview_female.as_ref()1724
};1793
};1725
let attachment = &mut preset.attachments[self.equipment_part];1794
let attachment = &mut preset.attachments[self.equipment_part];1726
ui.label("Positie relatief aan bot (XYZ)");1795
ui.label(crate::i18n::t("Positie relatief aan bot (XYZ)"));1727
ui.horizontal(|ui| {1796
ui.horizontal(|ui| {1728
for value in &mut attachment.translation {1797
for value in &mut attachment.translation {1729
ui.add(1798
ui.add(@@ -1733,7 +1802,7 @@ impl EditorApp {1733
);1802
);1734
}1803
}1735
});1804
});1736
ui.label("Rotatie in graden XYZ (Rz·Ry·Rx)");1805
ui.label(crate::i18n::t("Rotatie in graden XYZ (Rz·Ry·Rx)"));1737
ui.horizontal(|ui| {1806
ui.horizontal(|ui| {1738
for value in &mut attachment.rotation_deg {1807
for value in &mut attachment.rotation_deg {1739
ui.add(1808
ui.add(@@ -1751,14 +1820,14 @@ impl EditorApp {1751
.shiny1820
.shiny1752
.and_then(|i| preset.assets.get(i))1821
.and_then(|i| preset.assets.get(i))1753
.map(|a| a.path.clone());1822
.map(|a| a.path.clone());1754
ui.label(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into()));1823
ui.label(crate::i18n::t(shiny.unwrap_or_else(|| "Glanssterkte zonder texture".into())));1755
if ui.button("Kies shinymap (PNG)").clicked() {1824
if ui.button(crate::i18n::t("Kies shinymap (PNG)")).clicked() {1756
self.dialogs.start(DialogPurpose::EquipmentShiny(1825
self.dialogs.start(DialogPurpose::EquipmentShiny(1757
index,1826
index,1758
self.equipment_part,1827
self.equipment_part,1759
));1828
));1760
}1829
}1761
let clear_shiny = ui.button("Wis shinymap").clicked();1830
let clear_shiny = ui.button(crate::i18n::t("Wis shinymap")).clicked();1762
let selected = attachment.bones[sex].clone();1831
let selected = attachment.bones[sex].clone();1763
if clear_shiny {1832
if clear_shiny {1764
if let Err(error) =1833
if let Err(error) =@@ -1767,19 +1836,19 @@ impl EditorApp {1767
item.error = Some(error);1836
item.error = Some(error);1768
}1837
}1769
}1838
}1770
ui.label(format!(1839
ui.label(crate::i18n::t(format!(1771
"Bot: {}",1840
"Bot: {}",1772
selected.as_deref().unwrap_or("nog niet gekoppeld")1841
selected.as_deref().unwrap_or("nog niet gekoppeld")1773
));1842
)));1774
if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {1843
if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {1775
ui.add_enabled_ui(false, |ui| {1844
ui.add_enabled_ui(false, |ui| {1776
ui.collapsing("Skeleton / botten", |ui| {1845
ui.collapsing("Skeleton / botten", |ui| {1777
ui.label("Geen botten");1846
ui.label(crate::i18n::t("Geen botten"));1778
});1847
});1779
});1848
});1780
ui.colored_label(1849
ui.colored_label(1781
egui::Color32::LIGHT_RED,1850
egui::Color32::LIGHT_RED,1782
format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam."),1851
crate::i18n::t(format!("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam.")),1783
);1852
);1784
} else if let Some(body) = body {1853
} else if let Some(body) = body {1785
if preset.rigs[sex].as_ref()1854
if preset.rigs[sex].as_ref()@@ -1787,7 +1856,7 @@ impl EditorApp {1787
{1856
{1788
ui.colored_label(1857
ui.colored_label(1789
egui::Color32::LIGHT_RED,1858
egui::Color32::LIGHT_RED,1790
"Rigbinding ontbreekt of wijkt af; selecteer een bot.",1859
crate::i18n::t("Rigbinding ontbreekt of wijkt af; selecteer een bot."),1791
);1860
);1792
}1861
}1793
ui.collapsing("Skeleton / botten", |ui| {1862
ui.collapsing("Skeleton / botten", |ui| {@@ -1795,17 +1864,17 @@ impl EditorApp {1795
if let Err(error) =1864
if let Err(error) =1796
preset.bind(sex, body, self.equipment_part, joint)1865
preset.bind(sex, body, self.equipment_part, joint)1797
{1866
{1798
ui.colored_label(egui::Color32::LIGHT_RED, error);1867
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(error));1799
}1868
}1800
}1869
}1801
});1870
});1802
} else {1871
} else {1803
ui.colored_label(1872
ui.colored_label(1804
egui::Color32::LIGHT_RED,1873
egui::Color32::LIGHT_RED,1805
"Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.",1874
crate::i18n::t("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding."),1806
);1875
);1807
}1876
}1808
if ui.button("Verwijder onderdeel").clicked() {1877
if ui.button(crate::i18n::t("Verwijder onderdeel")).clicked() {1809
if let Err(error) =1878
if let Err(error) =1810
crate::equipment::remove_attachment(preset, self.equipment_part)1879
crate::equipment::remove_attachment(preset, self.equipment_part)1811
{1880
{@@ -1815,7 +1884,7 @@ impl EditorApp {1815
}1884
}1816
}1885
}1817
}1886
}1818
if ui.button("Verwijder equipmentdefinitie").clicked() {1887
if ui.button(crate::i18n::t("Verwijder equipmentdefinitie")).clicked() {1819
self.session.delete_equipment(index);1888
self.session.delete_equipment(index);1820
}1889
}1821
});1890
});@@ -1834,19 +1903,19 @@ impl EditorApp {1834
if ui1903
if ui1835
.add_enabled(1904
.add_enabled(1836
!self.render_ui.running && !static_body,1905
!self.render_ui.running && !static_body,1837
egui::Button::new("Render equipment · alle hoeken"),1906
egui::Button::new(crate::i18n::t("Render equipment · alle hoeken")),1838
)1907
)1839
.on_disabled_hover_text(1908
.on_disabled_hover_text(1840
"Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)",1909
crate::i18n::t("Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)"),1841
)1910
)1842
.clicked()1911
.clicked()1843
{1912
{1844
self.start_render();1913
self.start_render();1845
}1914
}1846
if ui.button("Live 3D").clicked() {1915
if ui.button(crate::i18n::t("Live 3D")).clicked() {1847
self.render_ui.preview = None;1916
self.render_ui.preview = None;1848
}1917
}1849
if ui.button("2D-preview").clicked() {1918
if ui.button(crate::i18n::t("2D-preview")).clicked() {1850
self.open_preview2d();1919
self.open_preview2d();1851
}1920
}1852
});1921
});@@ -1876,7 +1945,7 @@ impl EditorApp {1876
if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {1945
if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {1877
ui.colored_label(1946
ui.colored_label(1878
egui::Color32::from_rgb(230, 190, 90),1947
egui::Color32::from_rgb(230, 190, 90),1879
format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar."),1948
crate::i18n::t(format!("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar.")),1880
);1949
);1881
}1950
}1882
self.viewport1951
self.viewport@@ -2017,33 +2086,33 @@ impl EditorApp {2017
if self.session.confirm.is_some() || self.dialog_pending {2086
if self.session.confirm.is_some() || self.dialog_pending {2018
ui.disable();2087
ui.disable();2019
}2088
}2020
ui.heading("2D-preview");2089
ui.heading(crate::i18n::t("2D-preview"));2021
if ui.button("Terug").clicked() {2090
if ui.button(crate::i18n::t("Terug")).clicked() {2022
go_back = true;2091
go_back = true;2023
}2092
}2024
ui.separator();2093
ui.separator();2025
ui.label("Geslacht");2094
ui.label(crate::i18n::t("Geslacht"));2026
ui.horizontal(|ui| {2095
ui.horizontal(|ui| {2027
if ui2096
if ui2028
.selectable_label(self.preview2d.sex == Sex::Male, "Man")2097
.selectable_label(self.preview2d.sex == Sex::Male, crate::i18n::t("Man"))2029
.clicked()2098
.clicked()2030
{2099
{2031
set_sex = Some(Sex::Male);2100
set_sex = Some(Sex::Male);2032
}2101
}2033
if ui2102
if ui2034
.selectable_label(self.preview2d.sex == Sex::Female, "Vrouw")2103
.selectable_label(self.preview2d.sex == Sex::Female, crate::i18n::t("Vrouw"))2035
.clicked()2104
.clicked()2036
{2105
{2037
set_sex = Some(Sex::Female);2106
set_sex = Some(Sex::Female);2038
}2107
}2039
});2108
});2040
if ui.button("Vernieuw exportsets").clicked() {2109
if ui.button(crate::i18n::t("Vernieuw exportsets")).clicked() {2041
refresh_catalog = true;2110
refresh_catalog = true;2042
}2111
}2043
ui.label(format!(2112
ui.label(crate::i18n::t(format!(2044
"{} voltooide exportset(s) (.ppda + atlassen)",2113
"{} voltooide exportset(s) (.ppda + atlassen)",2045
self.preview2d_catalog.len()2114
self.preview2d_catalog.len()2046
));2115
)));2047
if !self.preview2d_skipped.is_empty() {2116
if !self.preview2d_skipped.is_empty() {2048
let reasons = self2117
let reasons = self2049
.preview2d_skipped2118
.preview2d_skipped@@ -2054,27 +2123,27 @@ impl EditorApp {2054
})2123
})2055
.collect::<Vec<_>>()2124
.collect::<Vec<_>>()2056
.join("\n");2125
.join("\n");2057
ui.weak(format!(2126
ui.weak(crate::i18n::t(format!(2058
"{} run(s) zonder geldige .ppda overgeslagen",2127
"{} run(s) zonder geldige .ppda overgeslagen",2059
self.preview2d_skipped.len()2128
self.preview2d_skipped.len()2060
))2129
)))2061
.on_hover_text(reasons);2130
.on_hover_text(crate::i18n::t(reasons));2062
}2131
}2063
ui.separator();2132
ui.separator();2064
ui.label("Equipment (zelfde project/rig/contract)");2133
ui.label(crate::i18n::t("Equipment (zelfde project/rig/contract)"));2065
let allowed =2134
let allowed =2066
compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);2135
compatible_equipment_sets(&self.preview2d_catalog, self.preview2d.sex);2067
if allowed.is_empty() {2136
if allowed.is_empty() {2068
ui.weak("Geen compatibele equipment-export.");2137
ui.weak(crate::i18n::t("Geen compatibele equipment-export."));2069
}2138
}2070
for set in allowed {2139
for set in allowed {2071
let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);2140
let mut checked = self.preview2d.selected_equipment.iter().any(|s| s == &set);2072
if ui.checkbox(&mut checked, &set).changed() {2141
if ui.checkbox(&mut checked, crate::i18n::t(&set)).changed() {2073
toggle_set = Some(set);2142
toggle_set = Some(set);2074
}2143
}2075
}2144
}2076
ui.separator();2145
ui.separator();2077
ui.label("Clip");2146
ui.label(crate::i18n::t("Clip"));2078
let sex_token = self.preview2d.sex_token();2147
let sex_token = self.preview2d.sex_token();2079
let clips: Vec<_> = self2148
let clips: Vec<_> = self2080
.preview2d_catalog2149
.preview2d_catalog@@ -2086,7 +2155,7 @@ impl EditorApp {2086
if clips.is_empty() {2155
if clips.is_empty() {2087
ui.colored_label(2156
ui.colored_label(2088
egui::Color32::LIGHT_RED,2157
egui::Color32::LIGHT_RED,2089
"Geen body-export met clips voor dit geslacht.",2158
crate::i18n::t("Geen body-export met clips voor dit geslacht."),2090
);2159
);2091
}2160
}2092
for (token, name, frames, duration) in &clips {2161
for (token, name, frames, duration) in &clips {@@ -2096,7 +2165,7 @@ impl EditorApp {2096
format!("{name} ({token}, {frames}f)")2165
format!("{name} ({token}, {frames}f)")2097
};2166
};2098
if ui2167
if ui2099
.selectable_label(self.preview2d.clip_token == *token, label)2168
.selectable_label(self.preview2d.clip_token == *token, crate::i18n::t(label))2100
.clicked()2169
.clicked()2101
{2170
{2102
select_clip = Some((token.clone(), *frames, *duration));2171
select_clip = Some((token.clone(), *frames, *duration));@@ -2109,57 +2178,57 @@ impl EditorApp {2109
} else {2178
} else {2110
"Speel"2179
"Speel"2111
};2180
};2112
if ui.button(play_label).clicked() {2181
if ui.button(crate::i18n::t(play_label)).clicked() {2113
toggle_play = true;2182
toggle_play = true;2114
}2183
}2115
ui.label(format!(2184
ui.label(crate::i18n::t(format!(2116
"frame {}/{} t={:.2}s",2185
"frame {}/{} t={:.2}s",2117
self.preview2d.frame_index() + 1,2186
self.preview2d.frame_index() + 1,2118
self.preview2d.clip_frames.max(1),2187
self.preview2d.clip_frames.max(1),2119
self.preview2d.elapsed_secs2188
self.preview2d.elapsed_secs2120
));2189
)));2121
});2190
});2122
ui.horizontal(|ui| {2191
ui.horizontal(|ui| {2123
ui.label("Snelheid");2192
ui.label(crate::i18n::t("Snelheid"));2124
ui.add(2193
ui.add(2125
egui::TextEdit::singleline(&mut self.preview2d_speed_text)2194
egui::TextEdit::singleline(&mut self.preview2d_speed_text)2126
.desired_width(48.0),2195
.desired_width(48.0),2127
);2196
);2128
if ui.button("Pas toe").clicked() {2197
if ui.button(crate::i18n::t("Pas toe")).clicked() {2129
apply_speed = true;2198
apply_speed = true;2130
}2199
}2131
ui.label(format!("export-fps {}", self.preview2d.fps));2200
ui.label(crate::i18n::t(format!("export-fps {}", self.preview2d.fps)));2132
});2201
});2133
ui.separator();2202
ui.separator();2134
ui.label("Weergave");2203
ui.label(crate::i18n::t("Weergave"));2135
for mode in ViewMode::all() {2204
for mode in ViewMode::all() {2136
if ui2205
if ui2137
.selectable_label(self.preview2d.view_mode == mode, mode.label_nl())2206
.selectable_label(self.preview2d.view_mode == mode, crate::i18n::t(mode.label_nl()))2138
.clicked()2207
.clicked()2139
{2208
{2140
set_view_mode = Some(mode);2209
set_view_mode = Some(mode);2141
}2210
}2142
}2211
}2143
ui.weak("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd).");2212
ui.weak(crate::i18n::t("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd)."));2144
ui.separator();2213
ui.separator();2145
ui.label("Hoek (export)");2214
ui.label(crate::i18n::t("Hoek (export)"));2146
ui.horizontal(|ui| {2215
ui.horizontal(|ui| {2147
if ui.button("Yaw −").clicked() {2216
if ui.button(crate::i18n::t("Yaw −")).clicked() {2148
step_yaw = -1;2217
step_yaw = -1;2149
}2218
}2150
if ui.button("Yaw +").clicked() {2219
if ui.button(crate::i18n::t("Yaw +")).clicked() {2151
step_yaw = 1;2220
step_yaw = 1;2152
}2221
}2153
ui.label(format!("yaw {}°", self.preview2d.yaw_deg()));2222
ui.label(crate::i18n::t(format!("yaw {}°", self.preview2d.yaw_deg())));2154
});2223
});2155
ui.horizontal(|ui| {2224
ui.horizontal(|ui| {2156
if ui.button("Pitch −").clicked() {2225
if ui.button(crate::i18n::t("Pitch −")).clicked() {2157
step_pitch = -1;2226
step_pitch = -1;2158
}2227
}2159
if ui.button("Pitch +").clicked() {2228
if ui.button(crate::i18n::t("Pitch +")).clicked() {2160
step_pitch = 1;2229
step_pitch = 1;2161
}2230
}2162
ui.label(format!("pitch {}°", self.preview2d.pitch_deg()));2231
ui.label(crate::i18n::t(format!("pitch {}°", self.preview2d.pitch_deg())));2163
});2232
});2164
ui.separator();2233
ui.separator();2165
ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {2234
ui.add_enabled_ui(self.preview2d.view_mode.uses_light(), |ui| {@@ -2167,10 +2236,10 @@ impl EditorApp {2167
let held = |r: &egui::Response| {2236
let held = |r: &egui::Response| {2168
held_ctx.input(|i| crate::preview2d::raw_pointer_hold(r.rect, i))2237
held_ctx.input(|i| crate::preview2d::raw_pointer_hold(r.rect, i))2169
};2238
};2170
ui.label("Licht (vasthouden; °/s — alleen Resultaat)");2239
ui.label(crate::i18n::t("Licht (vasthouden; °/s — alleen Resultaat)"));2171
ui.horizontal(|ui| {2240
ui.horizontal(|ui| {2172
let west = ui.button("Azimut −");2241
let west = ui.button(crate::i18n::t("Azimut −"));2173
let east = ui.button("Azimut +");2242
let east = ui.button(crate::i18n::t("Azimut +"));2174
if self.preview2d.view_mode.uses_light() {2243
if self.preview2d.view_mode.uses_light() {2175
if held(&west) {2244
if held(&west) {2176
hold_az = Some(HoldSign::Negative);2245
hold_az = Some(HoldSign::Negative);@@ -2183,11 +2252,11 @@ impl EditorApp {2183
release_az = true;2252
release_az = true;2184
}2253
}2185
}2254
}2186
ui.label(format!("{:.0}°", self.preview2d.light_azimuth_deg));2255
ui.label(crate::i18n::t(format!("{:.0}°", self.preview2d.light_azimuth_deg)));2187
});2256
});2188
ui.horizontal(|ui| {2257
ui.horizontal(|ui| {2189
let down = ui.button("Elevatie −");2258
let down = ui.button(crate::i18n::t("Elevatie −"));2190
let up = ui.button("Elevatie +");2259
let up = ui.button(crate::i18n::t("Elevatie +"));2191
if self.preview2d.view_mode.uses_light() {2260
if self.preview2d.view_mode.uses_light() {2192
if held(&down) {2261
if held(&down) {2193
hold_el = Some(HoldSign::Negative);2262
hold_el = Some(HoldSign::Negative);@@ -2200,10 +2269,10 @@ impl EditorApp {2200
release_el = true;2269
release_el = true;2201
}2270
}2202
}2271
}2203
ui.label(format!("{:.0}°", self.preview2d.light_elevation_deg));2272
ui.label(crate::i18n::t(format!("{:.0}°", self.preview2d.light_elevation_deg)));2204
});2273
});2205
ui.weak(2274
ui.weak(2206
"Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.",2275
crate::i18n::t("Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht."),2207
);2276
);2208
});2277
});2209
});2278
});@@ -2302,11 +2371,11 @@ impl EditorApp {2302
ui.disable();2371
ui.disable();2303
}2372
}2304
if let Some(diag) = &self.preview2d.diagnostic {2373
if let Some(diag) = &self.preview2d.diagnostic {2305
ui.colored_label(egui::Color32::LIGHT_RED, diag);2374
ui.colored_label(egui::Color32::LIGHT_RED, crate::i18n::t(diag));2306
}2375
}2307
let Some(image) = &self.preview2d_image else {2376
let Some(image) = &self.preview2d_image else {2308
ui.label(2377
ui.label(2309
"Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.",2378
crate::i18n::t("Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken."),2310
);2379
);2311
return;2380
return;2312
};2381
};@@ -2350,23 +2419,32 @@ impl EditorApp {2350
ui.disable();2419
ui.disable();2351
}2420
}2352
ui.horizontal(|ui| {2421
ui.horizontal(|ui| {2353
if ui.button("Body").clicked() {2422
if ui.button(crate::i18n::t("Body")).clicked() {2354
self.session.show_bodies();2423
self.session.show_bodies();2355
}2424
}2356
if ui.button("2D-preview").clicked() {2425
if ui.button(crate::i18n::t("2D-preview")).clicked() {2357
self.open_preview2d();2426
self.open_preview2d();2358
}2427
}2359
if ui.button("Opslaan").clicked() {2428
if ui.button(crate::i18n::t("Opslaan")).clicked() {2360
self.save();2429
self.save();2361
}2430
}2431
if ui.button(crate::i18n::t("Instellingen")).clicked() {2432
self.settings_open = true;2433
}2362
if ui2434
if ui2363
.add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))2435
.add_enabled(2436
!self.render_ui.running,2437
egui::Button::new(crate::i18n::t("Nieuw project")),2438
)2364
.clicked()2439
.clicked()2365
{2440
{2366
self.begin(Intent::PickNew);2441
self.begin(Intent::PickNew);2367
}2442
}2368
if ui2443
if ui2369
.add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))2444
.add_enabled(2445
!self.render_ui.running,2446
egui::Button::new(crate::i18n::t("Ander project")),2447
)2370
.clicked()2448
.clicked()2371
{2449
{2372
self.begin(Intent::PickLoad);2450
self.begin(Intent::PickLoad);@@ -2407,7 +2485,7 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)2407
if ui2485
if ui2408
.selectable_label(2486
.selectable_label(2409
selected == Some(paths[joint].as_str()),2487
selected == Some(paths[joint].as_str()),2410
format!("Koppel {label}"),2488
crate::i18n::t(format!("Koppel {label}")),2411
)2489
)2412
.clicked()2490
.clicked()2413
{2491
{@@ -2418,7 +2496,7 @@ fn bone_tree(ui: &mut egui::Ui, body: &PreviewCharacter, selected: Option<&str>)2418
if children.is_empty() {2496
if children.is_empty() {2419
select(ui, choice);2497
select(ui, choice);2420
} else {2498
} else {2421
egui::CollapsingHeader::new(&label)2499
egui::CollapsingHeader::new(crate::i18n::t(&label))2422
.id_salt(index)2500
.id_salt(index)2423
.default_open(depth < 2)2501
.default_open(depth < 2)2424
.show(ui, |ui| {2502
.show(ui, |ui| {@@ -2814,4 +2892,17 @@ mod tests {2814
assert!(!app.render_ui.running);2892
assert!(!app.render_ui.running);2815
assert!(app.render_ui.error.is_some());2893
assert!(app.render_ui.error.is_some());2816
}2894
}2895
2896
#[test]2897
fn language_choice_switches_at_runtime_and_is_stored() {2898
let config = root().join("lang-config");2899
std::fs::create_dir_all(&config).unwrap();2900
let mut app = EditorApp::new(Box::new(ScriptedDialogs::default()));2901
app.set_recent_dir(Some(config.clone()));2902
app.choose_language(crate::i18n::Lang::Nl);2903
assert_eq!(crate::i18n::load_language(&config), crate::i18n::Lang::Nl);2904
app.choose_language(crate::i18n::Lang::En);2905
assert_eq!(crate::i18n::load_language(&config), crate::i18n::Lang::En);2906
assert_eq!(crate::i18n::lang(), crate::i18n::Lang::En);2907
}2817
}2908
}@@ -0,0 +1,429 @@
1
//! UI language (Tim t1004u): English (default) or Dutch.
2
//!
3
//! Source strings are Dutch; [`t`] translates any displayed text at the UI
4
//! boundary through [`crate::i18n_table::TABLE`]. Entries may be format
5
//! templates whose `{…}` holes are matched and translated recursively.
6
use std::collections::HashMap;
7
use std::path::Path;
8
use std::sync::atomic::{AtomicU8, Ordering};
9
use std::sync::{Mutex, OnceLock};
10
11
use crate::i18n_table::TABLE;
12
13
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14
pub enum Lang {
15
#[default]
16
En,
17
Nl,
18
}
19
20
impl Lang {
21
pub const ALL: [Lang; 2] = [Lang::En, Lang::Nl];
22
pub fn code(self) -> &'static str {
23
match self {
24
Lang::En => "en",
25
Lang::Nl => "nl",
26
}
27
}
28
pub fn from_code(code: &str) -> Option<Self> {
29
Self::ALL.into_iter().find(|l| l.code() == code)
30
}
31
/// Name of the language in that language.
32
pub fn native_name(self) -> &'static str {
33
match self {
34
Lang::En => "English",
35
Lang::Nl => "Nederlands",
36
}
37
}
38
}
39
40
static LANG: AtomicU8 = AtomicU8::new(0);
41
42
pub fn lang() -> Lang {
43
if LANG.load(Ordering::Relaxed) == 1 {
44
Lang::Nl
45
} else {
46
Lang::En
47
}
48
}
49
50
pub fn set_lang(lang: Lang) {
51
LANG.store(u8::from(lang == Lang::Nl), Ordering::Relaxed);
52
}
53
54
/// Display text in the current language.
55
pub fn t(text: impl AsRef<str>) -> String {
56
tr(lang(), text.as_ref())
57
}
58
59
/// Display text in `lang`.
60
pub fn tr(lang: Lang, text: &str) -> String {
61
match lang {
62
Lang::Nl => text.to_string(),
63
Lang::En => to_english(text),
64
}
65
}
66
67
struct Template {
68
/// Literal parts; holes sit between consecutive parts.
69
parts: Vec<String>,
70
holes: Vec<String>,
71
english: &'static str,
72
weight: usize,
73
}
74
75
struct Tables {
76
exact: HashMap<&'static str, &'static str>,
77
templates: Vec<Template>,
78
}
79
80
/// Split a format string into literal parts and `{…}` hole tokens.
81
fn split_template(text: &str) -> (Vec<String>, Vec<String>) {
82
let mut parts = vec![String::new()];
83
let mut holes = Vec::new();
84
let mut chars = text.chars().peekable();
85
while let Some(c) = chars.next() {
86
match c {
87
'{' if chars.peek() == Some(&'{') => {
88
chars.next();
89
parts.last_mut().unwrap().push('{');
90
}
91
'}' if chars.peek() == Some(&'}') => {
92
chars.next();
93
parts.last_mut().unwrap().push('}');
94
}
95
'{' => {
96
let mut token = String::from("{");
97
for d in chars.by_ref() {
98
token.push(d);
99
if d == '}' {
100
break;
101
}
102
}
103
holes.push(token);
104
parts.push(String::new());
105
}
106
_ => parts.last_mut().unwrap().push(c),
107
}
108
}
109
(parts, holes)
110
}
111
112
fn tables() -> &'static Tables {
113
static T: OnceLock<Tables> = OnceLock::new();
114
T.get_or_init(|| {
115
let mut exact = HashMap::new();
116
let mut templates = Vec::new();
117
for (nl, en) in TABLE {
118
let (parts, holes) = split_template(nl);
119
if holes.is_empty() {
120
exact.insert(*nl, *en);
121
// `{{`/`}}`-free literal; also index its unescaped form.
122
continue;
123
}
124
let weight: usize = parts.iter().map(|p| p.chars().count()).sum();
125
if weight == 0 {
126
continue;
127
}
128
templates.push(Template {
129
parts,
130
holes,
131
english: en,
132
weight,
133
});
134
}
135
templates.sort_by_key(|t| std::cmp::Reverse(t.weight));
136
Tables { exact, templates }
137
})
138
}
139
140
/// Match `msg` against literal `parts` (holes in between, non-greedy, backtracking).
141
fn match_parts<'m>(msg: &'m str, parts: &[String]) -> Option<Vec<&'m str>> {
142
let rest = msg.strip_prefix(parts[0].as_str())?;
143
if parts.len() == 1 {
144
return rest.is_empty().then(Vec::new);
145
}
146
let next = &parts[1];
147
let mut start = 0;
148
loop {
149
let found = if next.is_empty() {
150
if parts.len() == 2 {
151
Some(rest.len())
152
} else {
153
// Empty separator between two holes: not supported (ambiguous).
154
return None;
155
}
156
} else {
157
rest[start..].find(next.as_str()).map(|i| start + i)
158
};
159
let at = found?;
160
let hole = &rest[..at];
161
let mut tail_parts = parts[1..].to_vec();
162
tail_parts[0] = next.clone();
163
if let Some(mut more) = match_parts(&rest[at..], &tail_parts) {
164
let mut out = vec![hole];
165
out.append(&mut more);
166
return Some(out);
167
}
168
if next.is_empty() {
169
return None;
170
}
171
// Advance to the next occurrence (keep char boundaries).
172
start = at + next.chars().next().map_or(1, char::len_utf8);
173
if start > rest.len() {
174
return None;
175
}
176
}
177
}
178
179
fn to_english(text: &str) -> String {
180
static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
181
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
182
if let Some(hit) = cache.lock().ok().and_then(|c| c.get(text).cloned()) {
183
return hit;
184
}
185
let out = translate(text, 0);
186
if let Ok(mut c) = cache.lock() {
187
if c.len() > 4096 {
188
c.clear();
189
}
190
c.insert(text.to_string(), out.clone());
191
}
192
out
193
}
194
195
fn translate(text: &str, depth: u32) -> String {
196
let tables = tables();
197
if let Some(en) = tables.exact.get(text) {
198
return (*en).to_string();
199
}
200
if depth > 6 || text.is_empty() {
201
return text.to_string();
202
}
203
// Multi-line text: translate per line.
204
if text.contains('\n') {
205
return text
206
.split('\n')
207
.map(|line| translate(line, depth + 1))
208
.collect::<Vec<_>>()
209
.join("\n");
210
}
211
for template in &tables.templates {
212
let Some(captures) = match_parts(text, &template.parts) else {
213
continue;
214
};
215
let translated: Vec<String> = captures.iter().map(|c| translate(c, depth + 1)).collect();
216
let (en_parts, en_holes) = split_template(template.english);
217
let mut used = vec![false; template.holes.len()];
218
let mut out = en_parts[0].clone();
219
for (k, token) in en_holes.iter().enumerate() {
220
let pick = (0..template.holes.len())
221
.find(|&i| !used[i] && template.holes[i] == *token)
222
.or_else(|| (0..template.holes.len()).find(|&i| !used[i]));
223
if let Some(i) = pick {
224
used[i] = true;
225
out.push_str(&translated[i]);
226
}
227
out.push_str(&en_parts[k + 1]);
228
}
229
return out;
230
}
231
text.to_string()
232
}
233
234
/// Settings file in the user config dir.
235
pub const SETTINGS_FILE: &str = "settings.json";
236
237
/// Read the stored language (English when missing or unreadable).
238
pub fn load_language(dir: &Path) -> Lang {
239
std::fs::read(dir.join(SETTINGS_FILE))
240
.ok()
241
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
242
.and_then(|v| v.get("language")?.as_str().and_then(Lang::from_code))
243
.unwrap_or_default()
244
}
245
246
/// Store the language atomically.
247
pub fn save_language(dir: &Path, lang: Lang) -> Result<(), String> {
248
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
249
let bytes = serde_json::to_vec_pretty(&serde_json::json!({ "language": lang.code() }))
250
.map_err(|e| e.to_string())?;
251
let temp = dir.join(format!("{SETTINGS_FILE}.tmp-{}", std::process::id()));
252
std::fs::write(&temp, bytes).map_err(|e| e.to_string())?;
253
std::fs::rename(&temp, dir.join(SETTINGS_FILE)).map_err(|e| e.to_string())
254
}
255
256
#[cfg(test)]
257
mod tests {
258
use super::*;
259
260
fn holes(s: &str) -> Vec<String> {
261
let mut h = split_template(s).1;
262
h.sort();
263
h
264
}
265
266
#[test]
267
fn every_entry_has_both_languages_with_the_same_placeholders() {
268
let mut seen = std::collections::HashSet::new();
269
for (nl, en) in TABLE {
270
assert!(
271
!nl.is_empty() && !en.trim().is_empty(),
272
"empty entry {nl:?}"
273
);
274
assert!(seen.insert(*nl), "duplicate key {nl:?}");
275
assert_eq!(holes(nl), holes(en), "placeholders differ: {nl:?} / {en:?}");
276
}
277
}
278
279
#[test]
280
fn exact_templates_nested_and_runtime_switch() {
281
assert_eq!(tr(Lang::En, "Project laden"), "Load project");
282
assert_eq!(tr(Lang::Nl, "Project laden"), "Project laden");
283
assert_eq!(
284
tr(Lang::En, "Oude onvoltooide render verwijderd: inprogress-1"),
285
"Old unfinished render removed: inprogress-1"
286
);
287
// Nested: outer template with a translated inner message.
288
assert_eq!(
289
tr(Lang::En, "atlas: raw files/x.png: PNG groter dan limiet"),
290
"atlas: raw files/x.png: PNG larger than the limit"
291
);
292
assert_eq!(
293
tr(
294
Lang::En,
295
"voltooide render faalt controle: .ppda: ongeldige fps"
296
),
297
"completed render fails verification: .ppda: invalid fps"
298
);
299
// Unknown text passes through.
300
assert_eq!(tr(Lang::En, "xyz 123"), "xyz 123");
301
set_lang(Lang::Nl);
302
assert_eq!(t("Opslaan"), "Opslaan");
303
set_lang(Lang::En);
304
assert_eq!(t("Opslaan"), "Save");
305
}
306
307
#[test]
308
fn settings_round_trip_and_default_english() {
309
let dir = std::env::temp_dir().join(format!("paperdoll-i18n-{}", std::process::id()));
310
let _ = std::fs::remove_dir_all(&dir);
311
assert_eq!(load_language(&dir), Lang::En);
312
save_language(&dir, Lang::Nl).unwrap();
313
assert_eq!(load_language(&dir), Lang::Nl);
314
std::fs::write(dir.join(SETTINGS_FILE), b"garbage").unwrap();
315
assert_eq!(load_language(&dir), Lang::En);
316
let _ = std::fs::remove_dir_all(dir);
317
}
318
319
/// Minimal Rust string-literal scanner for the coverage test.
320
fn literals(code: &str) -> Vec<String> {
321
let b = code.as_bytes();
322
let mut out = Vec::new();
323
let mut i = 0;
324
while i < b.len() {
325
if code[i..].starts_with("//") {
326
i = code[i..].find('\n').map_or(b.len(), |j| i + j);
327
continue;
328
}
329
if b[i] == b'\'' {
330
// char literal like '"' or '\\'
331
let rest = &code[i..];
332
let len = if rest.starts_with("'\\") { 4 } else { 3 };
333
if rest.len() >= len && rest.as_bytes()[len - 1] == b'\'' {
334
i += len;
335
continue;
336
}
337
}
338
if b[i] == b'r'
339
&& (i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_'))
340
&& code[i + 1..].starts_with(['"', '#'])
341
{
342
let hashes = code[i + 1..].chars().take_while(|c| *c == '#').count();
343
if code[i + 1 + hashes..].starts_with('"') {
344
let end = format!("\"{}", "#".repeat(hashes));
345
let start = i + 2 + hashes;
346
let j = code[start..].find(&end).map_or(b.len(), |j| start + j);
347
i = j + end.len();
348
continue;
349
}
350
}
351
if b[i] == b'"' {
352
let byte_lit = i > 0 && b[i - 1] == b'b';
353
let mut j = i + 1;
354
let mut s = String::new();
355
while j < b.len() && b[j] != b'"' {
356
if b[j] == b'\\' {
357
s.push_str(&code[j..j + 2]);
358
j += 2;
359
} else {
360
let c = code[j..].chars().next().unwrap();
361
s.push(c);
362
j += c.len_utf8();
363
}
364
}
365
if !byte_lit {
366
out.push(s);
367
}
368
i = j + 1;
369
continue;
370
}
371
i += code[i..].chars().next().map_or(1, char::len_utf8);
372
}
373
out
374
}
375
376
fn unescape(s: &str) -> String {
377
let mut out = String::new();
378
let mut chars = s.chars().peekable();
379
while let Some(c) = chars.next() {
380
if c != '\\' {
381
out.push(c);
382
continue;
383
}
384
match chars.next() {
385
Some('n') => out.push('\n'),
386
Some('t') => out.push('\t'),
387
Some('\n') => {
388
while chars.peek().is_some_and(|c| c.is_whitespace()) {
389
chars.next();
390
}
391
}
392
Some(other) => out.push(other),
393
None => {}
394
}
395
}
396
out
397
}
398
399
#[test]
400
fn every_user_visible_source_string_has_a_translation() {
401
let keys: std::collections::HashSet<&str> = TABLE.iter().map(|(nl, _)| *nl).collect();
402
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
403
let mut missing = Vec::new();
404
for entry in std::fs::read_dir(dir).unwrap() {
405
let path = entry.unwrap().path();
406
let name = path.file_name().unwrap().to_string_lossy().to_string();
407
if !name.ends_with(".rs")
408
|| name == "main.rs"
409
|| name == "i18n_table.rs"
410
|| name == "i18n.rs"
411
{
412
continue;
413
}
414
let code = std::fs::read_to_string(&path).unwrap();
415
let code = code
416
.find("\n#[cfg(test)]\nmod tests")
417
.map_or(code.as_str(), |i| &code[..i]);
418
for lit in literals(code) {
419
let text = unescape(&lit);
420
let letters = text.chars().filter(|c| c.is_alphabetic()).count() >= 3;
421
let wordy = text.contains(' ') || text.contains(['é', 'ë', 'ï']);
422
if letters && wordy && !keys.contains(text.as_str()) {
423
missing.push(format!("{name}: {text:?}"));
424
}
425
}
426
}
427
assert!(missing.is_empty(), "untranslated:\n{}", missing.join("\n"));
428
}
429
}
@@ -0,0 +1,429 @@1
//! UI language (Tim t1004u): English (default) or Dutch.2
//!3
//! Source strings are Dutch; [`t`] translates any displayed text at the UI4
//! boundary through [`crate::i18n_table::TABLE`]. Entries may be format5
//! templates whose `{…}` holes are matched and translated recursively.6
use std::collections::HashMap;7
use std::path::Path;8
use std::sync::atomic::{AtomicU8, Ordering};9
use std::sync::{Mutex, OnceLock};10
11
use crate::i18n_table::TABLE;12
13
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]14
pub enum Lang {15
#[default]16
En,17
Nl,18
}19
20
impl Lang {21
pub const ALL: [Lang; 2] = [Lang::En, Lang::Nl];22
pub fn code(self) -> &'static str {23
match self {24
Lang::En => "en",25
Lang::Nl => "nl",26
}27
}28
pub fn from_code(code: &str) -> Option<Self> {29
Self::ALL.into_iter().find(|l| l.code() == code)30
}31
/// Name of the language in that language.32
pub fn native_name(self) -> &'static str {33
match self {34
Lang::En => "English",35
Lang::Nl => "Nederlands",36
}37
}38
}39
40
static LANG: AtomicU8 = AtomicU8::new(0);41
42
pub fn lang() -> Lang {43
if LANG.load(Ordering::Relaxed) == 1 {44
Lang::Nl45
} else {46
Lang::En47
}48
}49
50
pub fn set_lang(lang: Lang) {51
LANG.store(u8::from(lang == Lang::Nl), Ordering::Relaxed);52
}53
54
/// Display text in the current language.55
pub fn t(text: impl AsRef<str>) -> String {56
tr(lang(), text.as_ref())57
}58
59
/// Display text in `lang`.60
pub fn tr(lang: Lang, text: &str) -> String {61
match lang {62
Lang::Nl => text.to_string(),63
Lang::En => to_english(text),64
}65
}66
67
struct Template {68
/// Literal parts; holes sit between consecutive parts.69
parts: Vec<String>,70
holes: Vec<String>,71
english: &'static str,72
weight: usize,73
}74
75
struct Tables {76
exact: HashMap<&'static str, &'static str>,77
templates: Vec<Template>,78
}79
80
/// Split a format string into literal parts and `{…}` hole tokens.81
fn split_template(text: &str) -> (Vec<String>, Vec<String>) {82
let mut parts = vec![String::new()];83
let mut holes = Vec::new();84
let mut chars = text.chars().peekable();85
while let Some(c) = chars.next() {86
match c {87
'{' if chars.peek() == Some(&'{') => {88
chars.next();89
parts.last_mut().unwrap().push('{');90
}91
'}' if chars.peek() == Some(&'}') => {92
chars.next();93
parts.last_mut().unwrap().push('}');94
}95
'{' => {96
let mut token = String::from("{");97
for d in chars.by_ref() {98
token.push(d);99
if d == '}' {100
break;101
}102
}103
holes.push(token);104
parts.push(String::new());105
}106
_ => parts.last_mut().unwrap().push(c),107
}108
}109
(parts, holes)110
}111
112
fn tables() -> &'static Tables {113
static T: OnceLock<Tables> = OnceLock::new();114
T.get_or_init(|| {115
let mut exact = HashMap::new();116
let mut templates = Vec::new();117
for (nl, en) in TABLE {118
let (parts, holes) = split_template(nl);119
if holes.is_empty() {120
exact.insert(*nl, *en);121
// `{{`/`}}`-free literal; also index its unescaped form.122
continue;123
}124
let weight: usize = parts.iter().map(|p| p.chars().count()).sum();125
if weight == 0 {126
continue;127
}128
templates.push(Template {129
parts,130
holes,131
english: en,132
weight,133
});134
}135
templates.sort_by_key(|t| std::cmp::Reverse(t.weight));136
Tables { exact, templates }137
})138
}139
140
/// Match `msg` against literal `parts` (holes in between, non-greedy, backtracking).141
fn match_parts<'m>(msg: &'m str, parts: &[String]) -> Option<Vec<&'m str>> {142
let rest = msg.strip_prefix(parts[0].as_str())?;143
if parts.len() == 1 {144
return rest.is_empty().then(Vec::new);145
}146
let next = &parts[1];147
let mut start = 0;148
loop {149
let found = if next.is_empty() {150
if parts.len() == 2 {151
Some(rest.len())152
} else {153
// Empty separator between two holes: not supported (ambiguous).154
return None;155
}156
} else {157
rest[start..].find(next.as_str()).map(|i| start + i)158
};159
let at = found?;160
let hole = &rest[..at];161
let mut tail_parts = parts[1..].to_vec();162
tail_parts[0] = next.clone();163
if let Some(mut more) = match_parts(&rest[at..], &tail_parts) {164
let mut out = vec![hole];165
out.append(&mut more);166
return Some(out);167
}168
if next.is_empty() {169
return None;170
}171
// Advance to the next occurrence (keep char boundaries).172
start = at + next.chars().next().map_or(1, char::len_utf8);173
if start > rest.len() {174
return None;175
}176
}177
}178
179
fn to_english(text: &str) -> String {180
static CACHE: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();181
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));182
if let Some(hit) = cache.lock().ok().and_then(|c| c.get(text).cloned()) {183
return hit;184
}185
let out = translate(text, 0);186
if let Ok(mut c) = cache.lock() {187
if c.len() > 4096 {188
c.clear();189
}190
c.insert(text.to_string(), out.clone());191
}192
out193
}194
195
fn translate(text: &str, depth: u32) -> String {196
let tables = tables();197
if let Some(en) = tables.exact.get(text) {198
return (*en).to_string();199
}200
if depth > 6 || text.is_empty() {201
return text.to_string();202
}203
// Multi-line text: translate per line.204
if text.contains('\n') {205
return text206
.split('\n')207
.map(|line| translate(line, depth + 1))208
.collect::<Vec<_>>()209
.join("\n");210
}211
for template in &tables.templates {212
let Some(captures) = match_parts(text, &template.parts) else {213
continue;214
};215
let translated: Vec<String> = captures.iter().map(|c| translate(c, depth + 1)).collect();216
let (en_parts, en_holes) = split_template(template.english);217
let mut used = vec![false; template.holes.len()];218
let mut out = en_parts[0].clone();219
for (k, token) in en_holes.iter().enumerate() {220
let pick = (0..template.holes.len())221
.find(|&i| !used[i] && template.holes[i] == *token)222
.or_else(|| (0..template.holes.len()).find(|&i| !used[i]));223
if let Some(i) = pick {224
used[i] = true;225
out.push_str(&translated[i]);226
}227
out.push_str(&en_parts[k + 1]);228
}229
return out;230
}231
text.to_string()232
}233
234
/// Settings file in the user config dir.235
pub const SETTINGS_FILE: &str = "settings.json";236
237
/// Read the stored language (English when missing or unreadable).238
pub fn load_language(dir: &Path) -> Lang {239
std::fs::read(dir.join(SETTINGS_FILE))240
.ok()241
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())242
.and_then(|v| v.get("language")?.as_str().and_then(Lang::from_code))243
.unwrap_or_default()244
}245
246
/// Store the language atomically.247
pub fn save_language(dir: &Path, lang: Lang) -> Result<(), String> {248
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;249
let bytes = serde_json::to_vec_pretty(&serde_json::json!({ "language": lang.code() }))250
.map_err(|e| e.to_string())?;251
let temp = dir.join(format!("{SETTINGS_FILE}.tmp-{}", std::process::id()));252
std::fs::write(&temp, bytes).map_err(|e| e.to_string())?;253
std::fs::rename(&temp, dir.join(SETTINGS_FILE)).map_err(|e| e.to_string())254
}255
256
#[cfg(test)]257
mod tests {258
use super::*;259
260
fn holes(s: &str) -> Vec<String> {261
let mut h = split_template(s).1;262
h.sort();263
h264
}265
266
#[test]267
fn every_entry_has_both_languages_with_the_same_placeholders() {268
let mut seen = std::collections::HashSet::new();269
for (nl, en) in TABLE {270
assert!(271
!nl.is_empty() && !en.trim().is_empty(),272
"empty entry {nl:?}"273
);274
assert!(seen.insert(*nl), "duplicate key {nl:?}");275
assert_eq!(holes(nl), holes(en), "placeholders differ: {nl:?} / {en:?}");276
}277
}278
279
#[test]280
fn exact_templates_nested_and_runtime_switch() {281
assert_eq!(tr(Lang::En, "Project laden"), "Load project");282
assert_eq!(tr(Lang::Nl, "Project laden"), "Project laden");283
assert_eq!(284
tr(Lang::En, "Oude onvoltooide render verwijderd: inprogress-1"),285
"Old unfinished render removed: inprogress-1"286
);287
// Nested: outer template with a translated inner message.288
assert_eq!(289
tr(Lang::En, "atlas: raw files/x.png: PNG groter dan limiet"),290
"atlas: raw files/x.png: PNG larger than the limit"291
);292
assert_eq!(293
tr(294
Lang::En,295
"voltooide render faalt controle: .ppda: ongeldige fps"296
),297
"completed render fails verification: .ppda: invalid fps"298
);299
// Unknown text passes through.300
assert_eq!(tr(Lang::En, "xyz 123"), "xyz 123");301
set_lang(Lang::Nl);302
assert_eq!(t("Opslaan"), "Opslaan");303
set_lang(Lang::En);304
assert_eq!(t("Opslaan"), "Save");305
}306
307
#[test]308
fn settings_round_trip_and_default_english() {309
let dir = std::env::temp_dir().join(format!("paperdoll-i18n-{}", std::process::id()));310
let _ = std::fs::remove_dir_all(&dir);311
assert_eq!(load_language(&dir), Lang::En);312
save_language(&dir, Lang::Nl).unwrap();313
assert_eq!(load_language(&dir), Lang::Nl);314
std::fs::write(dir.join(SETTINGS_FILE), b"garbage").unwrap();315
assert_eq!(load_language(&dir), Lang::En);316
let _ = std::fs::remove_dir_all(dir);317
}318
319
/// Minimal Rust string-literal scanner for the coverage test.320
fn literals(code: &str) -> Vec<String> {321
let b = code.as_bytes();322
let mut out = Vec::new();323
let mut i = 0;324
while i < b.len() {325
if code[i..].starts_with("//") {326
i = code[i..].find('\n').map_or(b.len(), |j| i + j);327
continue;328
}329
if b[i] == b'\'' {330
// char literal like '"' or '\\'331
let rest = &code[i..];332
let len = if rest.starts_with("'\\") { 4 } else { 3 };333
if rest.len() >= len && rest.as_bytes()[len - 1] == b'\'' {334
i += len;335
continue;336
}337
}338
if b[i] == b'r'339
&& (i == 0 || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_'))340
&& code[i + 1..].starts_with(['"', '#'])341
{342
let hashes = code[i + 1..].chars().take_while(|c| *c == '#').count();343
if code[i + 1 + hashes..].starts_with('"') {344
let end = format!("\"{}", "#".repeat(hashes));345
let start = i + 2 + hashes;346
let j = code[start..].find(&end).map_or(b.len(), |j| start + j);347
i = j + end.len();348
continue;349
}350
}351
if b[i] == b'"' {352
let byte_lit = i > 0 && b[i - 1] == b'b';353
let mut j = i + 1;354
let mut s = String::new();355
while j < b.len() && b[j] != b'"' {356
if b[j] == b'\\' {357
s.push_str(&code[j..j + 2]);358
j += 2;359
} else {360
let c = code[j..].chars().next().unwrap();361
s.push(c);362
j += c.len_utf8();363
}364
}365
if !byte_lit {366
out.push(s);367
}368
i = j + 1;369
continue;370
}371
i += code[i..].chars().next().map_or(1, char::len_utf8);372
}373
out374
}375
376
fn unescape(s: &str) -> String {377
let mut out = String::new();378
let mut chars = s.chars().peekable();379
while let Some(c) = chars.next() {380
if c != '\\' {381
out.push(c);382
continue;383
}384
match chars.next() {385
Some('n') => out.push('\n'),386
Some('t') => out.push('\t'),387
Some('\n') => {388
while chars.peek().is_some_and(|c| c.is_whitespace()) {389
chars.next();390
}391
}392
Some(other) => out.push(other),393
None => {}394
}395
}396
out397
}398
399
#[test]400
fn every_user_visible_source_string_has_a_translation() {401
let keys: std::collections::HashSet<&str> = TABLE.iter().map(|(nl, _)| *nl).collect();402
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");403
let mut missing = Vec::new();404
for entry in std::fs::read_dir(dir).unwrap() {405
let path = entry.unwrap().path();406
let name = path.file_name().unwrap().to_string_lossy().to_string();407
if !name.ends_with(".rs")408
|| name == "main.rs"409
|| name == "i18n_table.rs"410
|| name == "i18n.rs"411
{412
continue;413
}414
let code = std::fs::read_to_string(&path).unwrap();415
let code = code416
.find("\n#[cfg(test)]\nmod tests")417
.map_or(code.as_str(), |i| &code[..i]);418
for lit in literals(code) {419
let text = unescape(&lit);420
let letters = text.chars().filter(|c| c.is_alphabetic()).count() >= 3;421
let wordy = text.contains(' ') || text.contains(['é', 'ë', 'ï']);422
if letters && wordy && !keys.contains(text.as_str()) {423
missing.push(format!("{name}: {text:?}"));424
}425
}426
}427
assert!(missing.is_empty(), "untranslated:\n{}", missing.join("\n"));428
}429
}@@ -0,0 +1,816 @@
1
//! Generated translation table (Dutch source text → English). Each entry is a
2
//! source string or format template; `{…}` holes are matched at runtime.
3
//! Regenerate entries when UI/error text changes; `i18n` tests enforce coverage.
4
pub const TABLE: &[(&str, &str)] = &[
5
("glTF JSON: {e}", "glTF JSON: {e}"),
6
("mtllib ", "mtllib "),
7
("bump ", "bump "),
8
("norm ", "norm "),
9
("kies een bestand", "choose a file"),
10
("bijbehorende bestanden ontbreken naast het model: {}", "associated files are missing next to the model: {}"),
11
("de bestandsnaam is niet bruikbaar", "the file name is not usable"),
12
("{rel}: {e}", "{rel}: {e}"),
13
("{rel}: geen gewoon bestand of te groot", "{rel}: not a regular file or too large"),
14
("ongeldig pad", "invalid path"),
15
("bestand te groot", "file too large"),
16
("geen geldige GLB", "not a valid GLB"),
17
("GLB JSON-chunk afgekapt", "GLB JSON chunk truncated"),
18
("GLB JSON: {e}", "GLB JSON: {e}"),
19
("verwijzing '{rel}' ligt buiten de modelmap; kopieer de bestanden naast het model", "reference '{rel}' points outside the model folder; copy the files next to the model"),
20
("atlascel moet 1–256 pixels per as zijn", "atlas cell must be 1–256 pixels per axis"),
21
("atlas zonder rijen", "atlas without rows"),
22
("atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of animatieduur — de atlas wordt niet gesplitst", "the atlas would be {width}×{height} pixels ({columns} angles × {rows} frames); maximum {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Lower fps, target resolution or animation length — the atlas is never split"),
23
("te veel frames voor de atlas", "too many frames for the atlas"),
24
("ongeldig PNG-pad", "invalid PNG path"),
25
("atlas: {rel} ontbreekt in de checksums", "atlas: {rel} is missing from the checksums"),
26
("atlas: checksum van {rel} klopt niet meer", "atlas: checksum of {rel} no longer matches"),
27
("atlas: {rel}: {e}", "atlas: {rel}: {e}"),
28
("atlas: onverwachte depth", "atlas: unexpected depth"),
29
("atlas {} · rij {}/{}", "atlas {} · row {}/{}"),
30
("atlas-digest ontbreekt", "atlas digest missing"),
31
("render.json mist {p}", "render.json lacks {p}"),
32
("{p} is geen tekst", "{p} is not text"),
33
("atlas: niet alle losse maps zijn geverifieerd", "atlas: not all loose maps have been verified"),
34
("atlas · {name}", "atlas · {name}"),
35
(".ppda: depth_bits past niet bij format_version", ".ppda: depth_bits does not match format_version"),
36
(".ppda: {e}", ".ppda: {e}"),
37
(".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)", ".ppda format_version {} is not supported (expected {PPDA_FORMAT_VERSION}; version 1 = old global depth range)"),
38
(".ppda: export past niet bij format_version", ".ppda: export does not match format_version"),
39
(".ppda: exportgegevens passen niet bij de set", ".ppda: export data does not match the set"),
40
(".ppda: ongeldig geslacht", ".ppda: invalid sex"),
41
(".ppda: ongeldig settype", ".ppda: invalid set type"),
42
(".ppda-bestandsnaam '{file_name}' past niet bij {}/{}", ".ppda file name '{file_name}' does not match {}/{}"),
43
(".ppda: project id/naam ongeldig", ".ppda: invalid project id/name"),
44
(".ppda: body_model_sha256 ongeldig", ".ppda: invalid body_model_sha256"),
45
(".ppda: equipment_preset_sha256 past niet bij settype", ".ppda: equipment_preset_sha256 does not match the set type"),
46
(".ppda: onbekende pivot_source", ".ppda: unknown pivot_source"),
47
(".ppda: camera-/dieptecontract wijkt af", ".ppda: camera/depth contract differs"),
48
(".ppda: ongeldige fps", ".ppda: invalid fps"),
49
(".ppda: pitches/yaws wijken af van −60…+60 × 0…330", ".ppda: pitches/yaws differ from −60…+60 × 0…330"),
50
(".ppda: clip-inventaris ongeldig", ".ppda: invalid clip inventory"),
51
(".ppda: te veel frames", ".ppda: too many frames"),
52
(".ppda: clips niet in bronvolgorde", ".ppda: clips not in source order"),
53
(".ppda: ongeldige clip-token '{}'", ".ppda: invalid clip token '{}'"),
54
(".ppda: clipnaam/duur ongeldig", ".ppda: invalid clip name/duration"),
55
(".ppda: frame_count past niet bij frames", ".ppda: frame_count does not match frames"),
56
(".ppda: frame-index of aantal hoeken ongeldig", ".ppda: invalid frame index or number of angles"),
57
(".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet", ".ppda: cell coordinates of {} f{k} p{} y{} are wrong"),
58
(".ppda: maps moet precies diffuse/normal/depth/specular bevatten", ".ppda: maps must contain exactly diffuse/normal/depth/specular"),
59
(".ppda mist map {}", ".ppda lacks map {}"),
60
(".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap", ".ppda: atlas file '{}' is not the expected name in the export folder"),
61
(".ppda: atlas sha256 ongeldig", ".ppda: invalid atlas sha256"),
62
(".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}", ".ppda: {} atlas size {}x{} differs from grid {}x{}"),
63
(".ppda zonder map", ".ppda without folder"),
64
("ongeldige .ppda-naam", "invalid .ppda name"),
65
(".ppda is geen UTF-8", ".ppda is not UTF-8"),
66
("checksum mismatch voor '{}'", "checksum mismatch for '{}'"),
67
("atlas PNG header: {e}", "atlas PNG header: {e}"),
68
("{} atlas: afmetingen of kleurtype wijken af van .ppda", "{} atlas: size or color type differs from .ppda"),
69
("geen .ppda in deze exportmap", "no .ppda in this export folder"),
70
("meer dan één .ppda in deze exportmap", "more than one .ppda in this export folder"),
71
("atlasset te groot om te laden ({decoded} bytes gedecodeerd)", "atlas set too large to load ({decoded} bytes decoded)"),
72
("clip '{clip_token}' niet in .ppda", "clip '{clip_token}' not in .ppda"),
73
("frame {frame} niet in clip '{clip_token}'", "frame {frame} not in clip '{clip_token}'"),
74
("hoek p{pitch} y{yaw} niet in .ppda", "angle p{pitch} y{yaw} not in .ppda"),
75
("atlascel {} f{} p{} y{} {} wijkt af van losse PNG", "atlas cell {} f{} p{} y{} {} differs from the loose PNG"),
76
("{cells} atlascellen ≠ {} losse PNG's", "{cells} atlas cells ≠ {} loose PNGs"),
77
("Vaste pivot Y {y:.3} m", "Fixed pivot Y {y:.3} m"),
78
("Nekpivot {joint_name}: Y {y:.3} m (rustpose)", "Neck pivot {joint_name}: Y {y:.3} m (rest pose)"),
79
("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in", "{reason}; center Y {y:.3} m as fallback — set the pivot manually"),
80
("Statisch lichaam zonder skeleton: geen nekbot", "Static body without skeleton: no neck bone"),
81
("Geen nekbot gevonden", "No neck bone found"),
82
("Meerdere losstaande/ongeldige nekketens", "Several separate/invalid neck chains"),
83
("Ongeldige rustpose van nekbot", "Invalid rest pose of the neck bone"),
84
("Nekhoogte buiten -10…10 m", "Neck height outside -10…10 m"),
85
("linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]", "linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]"),
86
("RGB-gepakt (nearest, lossless)", "RGB packed (nearest, lossless)"),
87
("depth_encoding onbekend", "unknown depth_encoding"),
88
("depth_bits moet 8 of 16 zijn", "depth_bits must be 8 or 16"),
89
("{fmt} gray+alpha; t=gray/{}", "{fmt} gray+alpha; t=gray/{}"),
90
("{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}", "{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}"),
91
("{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only", "{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only"),
92
("; distance=depth_near+t*(depth_far-depth_near), clamped", "; distance=depth_near+t*(depth_far-depth_near), clamped"),
93
("depth rgb_bands: ongeldige bandvolgorde", "depth rgb_bands: invalid band order"),
94
("geen lagen om te composeren", "no layers to compose"),
95
("laagmaps hebben niet dezelfde afmetingen", "layer maps do not have the same size"),
96
("afmetingen overlopen", "size overflow"),
97
("buffergrootte past niet bij breedte×hoogte", "buffer size does not match width×height"),
98
("depth-weergave past niet bij de cel", "depth display does not match the cell"),
99
("{kind} verwacht RGBA8, kreeg depth16", "{kind} expects RGBA8, got depth16"),
100
("depth verwacht gray-alpha16, kreeg RGBA8", "depth expects gray-alpha16, got RGBA8"),
101
("project.id verschilt tussen exportsets", "project.id differs between export sets"),
102
("geslacht verschilt tussen exportsets", "sex differs between export sets"),
103
("body_model fingerprint verschilt", "body_model fingerprint differs"),
104
("target-resolutie verschilt", "target resolution differs"),
105
("fps verschilt", "fps differs"),
106
("framing verschilt", "framing differs"),
107
("near/far verschilt", "near/far differs"),
108
("clip-inventaris verschilt", "clip inventory differs"),
109
("lege afmetingen", "empty size"),
110
("laag {index} ('{}') heeft andere afmetingen", "layer {index} ('{}') has a different size"),
111
("laag {index} ('{}') heeft inconsistente buffers", "layer {index} ('{}') has inconsistent buffers"),
112
("maximaal 255 lagen voor winner-index", "at most 255 layers for the winner index"),
113
("composed buffers passen niet bij afmetingen", "composed buffers do not match the size"),
114
("Map voor het nieuwe project", "Folder for the new project"),
115
("Project laden", "Load project"),
116
("Rigide equipment toevoegen", "Add rigid equipment"),
117
("Rigide model", "Rigid model"),
118
("Wavefront OBJ", "Wavefront OBJ"),
119
("Equipment-preset importeren", "Import equipment preset"),
120
("Paperdoll equipment", "Paperdoll equipment"),
121
("bronbestand is geen regulier bestand", "source file is not a regular file"),
122
("bronbestand groter dan {max_bytes} bytes", "source file larger than {max_bytes} bytes"),
123
("Render alles{}: {} gerenderd, {} overgeslagen, {} mislukt", "Render all{}: {} rendered, {} skipped, {} failed"),
124
(" (geannuleerd)", " (cancelled)"),
125
(", {} niet gestart", ", {} not started"),
126
("kies eerst een map waarin het project komt", "choose the folder for the project first"),
127
("Nieuw project", "New project"),
128
("nog niet gekozen", "not chosen yet"),
129
("Map kiezen…", "Choose folder…"),
130
("Wordt aangemaakt: {}", "Will be created: {}"),
131
("{error} (ontbrekende bijbehorende bestanden: {})", "{error} (missing associated files: {})"),
132
("3D: {error}", "3D: {error}"),
133
("{relative}: {error}", "{relative}: {error}"),
134
("{relative} staat in het project.", "{relative} is in the project."),
135
("Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot.", "Part embedded including material and textures. Choose the target bone."),
136
("geen project geopend", "no project open"),
137
("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model", "this body has no drawable glTF/GLB, FBX or OBJ model"),
138
("geen projectmap", "no project folder"),
139
("configureer eerst het equipment", "configure the equipment first"),
140
("dit lichaam heeft geen modelpad", "this body has no model path"),
141
("body-fingerprint: {e}", "body-fingerprint: {e}"),
142
("equipment-fingerprint: {e}", "equipment-fingerprint: {e}"),
143
("{name} · body", "{name} · body"),
144
("geen lichaam ingesteld", "no body set"),
145
("{} · equipment", "{} · equipment"),
146
("equipment is niet geconfigureerd", "equipment is not configured"),
147
("{name} · {}", "{name} · {}"),
148
("geen lichaam ingesteld voor dit geslacht", "no body set for this sex"),
149
("geannuleerd (hervatbaar)", "cancelled (resumable)"),
150
("Render alles: item {}/{} · {}", "Render all: item {}/{} · {}"),
151
("✔ {label}", "✔ {label}"),
152
("overgeslagen: {label} — {reason}", "skipped: {label} — {reason}"),
153
("mislukt: {label} — {reason}", "failed: {label} — {reason}"),
154
("niet gestart: {label}", "not started: {label}"),
155
("geen renderbare job voor deze weergave", "no renderable job for this view"),
156
("Onderbroken renders:", "Interrupted renders:"),
157
("Zoek onderbroken runs", "Look for interrupted runs"),
158
("Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.", "Only runs with exactly the same sources and settings can continue."),
159
("geen gevonden voor deze set", "none found for this set"),
160
("{} · {} · {} van {} PNG's vastgelegd", "{} · {} · {} of {} PNGs recorded"),
161
("Glow-context ontbreekt", "Glow context missing"),
162
("Batch opgeslagen: {path}", "Batch saved: {path}"),
163
("De renderworker stopte zonder eindresultaat.", "The render worker stopped without a final result."),
164
("Annuleer render", "Cancel render"),
165
("Vaste renderhoogte (m)", "Fixed render height (m)"),
166
("Centrum Y (m)", "Center Y (m)"),
167
("Pivot Y (m)", "Pivot Y (m)"),
168
("Auto nek", "Auto neck"),
169
("Wis handmatige pivot; gebruik rustpose-nekhoogte", "Clear manual pivot; use the rest-pose neck height"),
170
("Omhullende animaties", "Fit animations"),
171
("Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.", "One-off: height and center so that all clips, frames and 60 angles fit inside the frame (+5% margin). Fixed again afterwards; no autozoom."),
172
("Vast renderkader tonen", "Show fixed render frame"),
173
("Dieptebereik (m)", "Depth range (m)"),
174
("T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot", "T-pose span + one arm length margin on each side, around the pivot"),
175
("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)", "near {near:.3} m · far {far:.3} m (fixed for all frames, angles and equipment)"),
176
("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the neck pivot."),
177
("{} niveaus", "{} levels"),
178
("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).", "RGB packed requires nearest sampling, no mipmaps and lossless storage (no texture compression)."),
179
("Er zijn wijzigingen die nog niet zijn opgeslagen.", "There are unsaved changes."),
180
("Niet opslaan", "Don't save"),
181
("Paperdoll Renderer", "Paperdoll Renderer"),
182
("Open een bestaand character.ppd of kies een map voor een nieuw project.", "Open an existing character.ppd or choose a folder for a new project."),
183
("Recent geopend", "Recently opened"),
184
("{name} (niet gevonden)", "{name} (not found)"),
185
("Kies man", "Choose male"),
186
("Open man", "Open male"),
187
("Kies vrouw", "Choose female"),
188
("Open vrouw", "Open female"),
189
("Render alles", "Render all"),
190
("Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.", "Renders all bodies and then all equipment per valid sex, each to renders/<geslacht>-<settype>-atlas/."),
191
("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.", "Opens the rendered 2D compositor over the result folders in renders/."),
192
("geen model", "no model"),
193
("Importoverzicht {title}", "Import overview {title}"),
194
("{}: {} vertices, {} driehoeken", "{}: {} vertices, {} triangles"),
195
("materiaal {}", "material {}"),
196
("texture {}: {}×{}", "texture {}: {}×{}"),
197
("{} joints", "{} joints"),
198
("Render alle hoeken", "Render all angles"),
199
("Live 3D", "Live 3D"),
200
("{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken.", "{reason}. Render all angles makes one bind-pose sample (clip000-bind) × 60 angles."),
201
("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.", "Open a glTF/GLB, FBX or OBJ body for the live 3D view."),
202
("Laatste voltooide pass: {}", "Last completed pass: {}"),
203
("pass texture installed", "pass texture installed"),
204
("Bodies → {} → Equipment", "Bodies → {} → Equipment"),
205
("Terug naar body", "Back to body"),
206
("{} {model}", "{} {model}"),
207
("Preset importeren", "Import preset"),
208
("Terug naar equipment", "Back to equipment"),
209
("Naam (kleine letters/cijfers/-/_)", "Name (lowercase letters/digits/-/_)"),
210
("Ongeldige naam; 'body' is gereserveerd.", "Invalid name; 'body' is reserved."),
211
("Onderdeel toevoegen (OBJ/FBX)", "Add part (OBJ/FBX)"),
212
("Geldig voor:", "Valid for:"),
213
("Kies minstens één geslacht.", "Choose at least one sex."),
214
("Herkomst / licentie (max. 4096 UTF-8 bytes)", "Origin / license (max. 4096 UTF-8 bytes)"),
215
("Herkomst/licentie is te lang.", "Origin/license is too long."),
216
("Positie relatief aan bot (XYZ)", "Position relative to bone (XYZ)"),
217
("Rotatie in graden XYZ (Rz·Ry·Rx)", "Rotation in degrees XYZ (Rz·Ry·Rx)"),
218
("Glanssterkte zonder texture", "Gloss strength without texture"),
219
("Kies shinymap (PNG)", "Choose shiny map (PNG)"),
220
("Wis shinymap", "Clear shiny map"),
221
("Bot: {}", "Bone: {}"),
222
("nog niet gekoppeld", "not linked yet"),
223
("Skeleton / botten", "Skeleton / bones"),
224
("Geen botten", "No bones"),
225
("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam.", "{reason}. Bone linking is not available; choose a skinned glTF/GLB or FBX body."),
226
("Rigbinding ontbreekt of wijkt af; selecteer een bot.", "Rig binding missing or different; select a bone."),
227
("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.", "Open a skinned glTF/GLB or FBX body for this rig binding."),
228
("Verwijder onderdeel", "Remove part"),
229
("Verwijder equipmentdefinitie", "Remove equipment definition"),
230
("Render equipment · alle hoeken", "Render equipment · all angles"),
231
("Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)", "Not available: static body without skeleton (no bone to attach to)"),
232
("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar.", "{reason}. Linking and rendering equipment is not available for this body."),
233
("geen clip in de voltooide body-export", "no clip in the completed body export"),
234
("geen selectie", "no selection"),
235
("Vernieuw exportsets", "Refresh export sets"),
236
("{} voltooide exportset(s) (.ppda + atlassen)", "{} completed export set(s) (.ppda + atlases)"),
237
("{name}: {why}", "{name}: {why}"),
238
("{} run(s) zonder geldige .ppda overgeslagen", "{} run(s) without a valid .ppda skipped"),
239
("Equipment (zelfde project/rig/contract)", "Equipment (same project/rig/contract)"),
240
("Geen compatibele equipment-export.", "No compatible equipment export."),
241
("Geen body-export met clips voor dit geslacht.", "No body export with clips for this sex."),
242
("{token} ({frames}f)", "{token} ({frames}f)"),
243
("{name} ({token}, {frames}f)", "{name} ({token}, {frames}f)"),
244
("frame {}/{} t={:.2}s", "frame {}/{} t={:.2}s"),
245
("Pas toe", "Apply"),
246
("export-fps {}", "export fps {}"),
247
("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd).", "Result = lit composite; the other modes show the map as stored (unlit, not decoded)."),
248
("Hoek (export)", "Angle (export)"),
249
("Yaw −", "Yaw −"),
250
("Yaw +", "Yaw +"),
251
("yaw {}°", "yaw {}°"),
252
("Pitch −", "Pitch −"),
253
("Pitch +", "Pitch +"),
254
("pitch {}°", "pitch {}°"),
255
("Licht (vasthouden; °/s — alleen Resultaat)", "Light (hold; °/s — Result only)"),
256
("Azimut −", "Azimuth −"),
257
("Azimut +", "Azimuth +"),
258
("Elevatie −", "Elevation −"),
259
("Elevatie +", "Elevation +"),
260
("Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.", "Azimuth wraps 0…360°; elevation clamped −80…+80. Only Result uses light."),
261
("Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.", "No composed frame. Render the body (and optionally equipment) first with Render all angles."),
262
("Ander project", "Other project"),
263
("Koppel {label}", "Link {label}"),
264
("ongeldig geslacht", "invalid sex"),
265
("dit bot bestaat niet", "this bone does not exist"),
266
("dit onderdeel bestaat niet", "this part does not exist"),
267
("equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'", "equipment name: lowercase letters/digits/-/_, no spaces, and not 'body'"),
268
("herkomst/licentie is te lang of bevat NUL", "origin/license is too long or contains NUL"),
269
("kies minstens één geslacht", "choose at least one sex"),
270
("te veel onderdelen of bestanden", "too many parts or files"),
271
("ongeldige, dubbele of te grote asset", "invalid, duplicate or too large asset"),
272
("te veel assetdata", "too much asset data"),
273
("ongeldige rigbinding", "invalid rig binding"),
274
("ongeldige bronassetverwijzing", "invalid source asset reference"),
275
("attachment verwijst naar onbekend bot", "attachment refers to an unknown bone"),
276
("ongeldige attachment/transformatie", "invalid attachment/transform"),
277
("ongeldige of te grote equipmentmesh", "invalid or too large equipment mesh"),
278
("textures samen groter dan 64 MiB", "textures together larger than 64 MiB"),
279
("textureverwijzing ontbreekt", "texture reference missing"),
280
("presetmetadata is te groot", "preset metadata is too large"),
281
("preset te groot", "preset too large"),
282
("preset is te groot", "preset is too large"),
283
("ongeldig equipmentbestand of bestand te groot", "invalid equipment file or file too large"),
284
("onbekende equipmentversie", "unknown equipment version"),
285
("beschadigde equipmentdata", "damaged equipment data"),
286
("te veel assets", "too many assets"),
287
("afgebroken asset", "truncated asset"),
288
("ongeldige assetlengte", "invalid asset length"),
289
("onverwachte extra equipmentdata", "unexpected extra equipment data"),
290
("kies een regulier presetbestand", "choose a regular preset file"),
291
("snapshot te groot", "snapshot too large"),
292
("snapshot is geen regulier bestand", "snapshot is not a regular file"),
293
("snapshotnaam bestaat met andere inhoud", "snapshot name exists with different content"),
294
("snapshotmap ontbreekt", "snapshot folder missing"),
295
("texture is groter dan 2048×2048", "texture is larger than 2048×2048"),
296
("ongeldige texturegrootte", "invalid texture size"),
297
("texture is te groot", "texture is too large"),
298
("onbekende texturecodering", "unknown texture encoding"),
299
("texture ontbreekt", "texture missing"),
300
("rig wijkt af: kies de botten opnieuw voor dit lichaam", "rig differs: choose the bones again for this body"),
301
("kies een bot voor dit onderdeel en geslacht", "choose a bone for this part and sex"),
302
("het gekoppelde bot ontbreekt", "the linked bone is missing"),
303
("kies een rigide OBJ-model met MTL/PNG-bijbestanden", "choose a rigid OBJ model with MTL/PNG side files"),
304
("modelmap ontbreekt", "model folder missing"),
305
("ongeldige modelnaam", "invalid model name"),
306
("te veel onderdelen", "too many parts"),
307
("disp ", "disp "),
308
("ongeldige textureverwijzing", "invalid texture reference"),
309
("ongeldig texturepad", "invalid texture path"),
310
("te veel bijbestanden", "too many side files"),
311
("assets samen te groot", "assets together too large"),
312
("equipmentmesh is te groot", "equipment mesh is too large"),
313
("ontbrekende diffuse texture", "missing diffuse texture"),
314
("het OBJ-bestand heeft geen meshes", "the OBJ file has no meshes"),
315
("kies een rigide OBJ- (met MTL/PNG) of FBX-model", "choose a rigid OBJ (with MTL/PNG) or FBX model"),
316
("kies een rigide FBX-model", "choose a rigid FBX model"),
317
("FBX laden mislukt: {}", "loading FBX failed: {}"),
318
("FBX heeft geometrie genegeerd; bestand is niet tekenbaar", "FBX ignored geometry; the file is not drawable"),
319
("assets samen te groot of te veel bijbestanden", "assets together too large or too many side files"),
320
("FBX-mesh '{}' is geskinned; alleen rigide equipment wordt ondersteund", "FBX mesh '{}' is skinned; only rigid equipment is supported"),
321
("FBX-mesh '{}' heeft morph targets; die worden niet ondersteund", "FBX mesh '{}' has morph targets; these are not supported"),
322
("FBX-mesh bevat niet-eindige posities of normalen", "FBX mesh contains non-finite positions or normals"),
323
("FBX-materiaal heeft een niet-eindige kleur", "FBX material has a non-finite color"),
324
("FBX-texture heeft geen relatief pad en is niet ingebed", "FBX texture has no relative path and is not embedded"),
325
("FBX-texturepad geweigerd: {e}", "FBX texture path refused: {e}"),
326
("FBX-texture '{relative}': {e}", "FBX-texture '{relative}': {e}"),
327
("FBX-texture is te groot", "FBX texture is too large"),
328
("FBX-texture is geen bruikbare PNG (alleen PNG wordt ingesloten): {e}", "FBX texture is not a usable PNG (only PNG is embedded): {e}"),
329
("het FBX-bestand heeft geen tekenbare meshes", "the FBX file has no drawable meshes"),
330
("texturemap ontbreekt", "texture folder missing"),
331
("ongeldige bestandsnaam", "invalid file name"),
332
("onderdeel ontbreekt", "part missing"),
333
("finite asset list", "finite asset list"),
334
("ongeldige assetverwijzing", "invalid asset reference"),
335
("asset verlaat de modelmap", "asset leaves the model folder"),
336
("asset is geen regulier bestand", "asset is not a regular file"),
337
("asset is te groot", "asset is too large"),
338
("ongeldige OBJ-index", "invalid OBJ index"),
339
("view (+Y up), PNG rows top-down", "view (+Y up), PNG rows top-down"),
340
("camera on +Z", "camera on +Z"),
341
("synthetic fixture", "synthetic fixture"),
342
("run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig", "run is cancelled or failed; complete.json is missing or invalid"),
343
("complete.json ontbreekt; de exportset is niet volledig", "complete.json is missing; the export set is not complete"),
344
("render.json: {e}", "render.json: {e}"),
345
("complete.json: {e}", "complete.json: {e}"),
346
("complete.json png_sha256 telt niet gelijk aan png_files", "complete.json png_sha256 count does not equal png_files"),
347
("png_files in complete.json wijkt af van render.json", "png_files in complete.json differs from render.json"),
348
("PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes", "PNG '{relative}' larger than {MAX_PNG_BYTES} bytes"),
349
("checksum mismatch voor '{relative}'", "checksum mismatch for '{relative}'"),
350
("ongeldig map-pad", "invalid map path"),
351
("map '{key}' staat niet in complete.json", "map '{key}' is not in complete.json"),
352
("onbekende map '{relative}'", "unknown map '{relative}'"),
353
("geen map-token in '{relative}'", "no map token in '{relative}'"),
354
("onbekend map-token '{token}' in '{relative}'", "unknown map token '{token}' in '{relative}'"),
355
("verwachtte {} maar pad is {}", "expected {} but path is {}"),
356
("depth_bits past niet bij render.json-schema", "depth_bits does not match the render.json schema"),
357
("render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen", "render.json schema 4: old export with global depth range 0.05–40 m; render again"),
358
("render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})", "render.json format_version {} is not supported (expected {OUTPUT_META_VERSION})"),
359
("onbekende render.json status '{}'", "unknown render.json status '{}'"),
360
("ongeldige setnaam", "invalid set name"),
361
("target buiten 1–256", "target outside 1–256"),
362
("ongeldige fps", "invalid fps"),
363
("highres_scale wijkt af van 8", "highres_scale differs from 8"),
364
("near/far wijkt af van het vaste dieptebereik van deze framing", "near/far differs from the fixed depth range of this framing"),
365
("camera_distance wijkt af van contract", "camera_distance differs from the contract"),
366
("pitches wijken af van −60,−30,0,+30,+60", "pitches differ from −60,−30,0,+30,+60"),
367
("yaws wijken af van 0…330 stap 30", "yaws differ from 0…330 step 30"),
368
("pass_order wijkt af van contract", "pass_order differs from the contract"),
369
("saved_maps wijkt af van diffuse/depth/normal/specular", "saved_maps differs from diffuse/depth/normal/specular"),
370
("clip-inventaris ongeldig", "invalid clip inventory"),
371
("ongeldige clip-token '{}'", "invalid clip token '{}'"),
372
("clipnaam te lang", "clip name too long"),
373
("clip zonder frames", "clip without frames"),
374
("ongeldige clipduur", "invalid clip duration"),
375
("png_files buiten limiet", "png_files outside the limit"),
376
("body_model.sha256 is geen SHA-256 hex", "body_model.sha256 is not SHA-256 hex"),
377
("project.id ongeldig", "invalid project.id"),
378
("project.name te lang", "project.name too long"),
379
("equipment_preset past niet bij set of mist geldige sha256", "equipment_preset does not match the set or lacks a valid sha256"),
380
("onbekende pivot_source.kind '{other}'", "unknown pivot_source.kind '{other}'"),
381
("complete.json format_version {} wordt niet ondersteund", "complete.json format_version {} is not supported"),
382
("complete.json status is niet 'complete'", "complete.json status is not 'complete'"),
383
("te veel png_sha256-items", "too many png_sha256 items"),
384
("identity.body_model_sha256 ongeldig", "invalid identity.body_model_sha256"),
385
("identity.equipment_preset_sha256 ongeldig", "invalid identity.equipment_preset_sha256"),
386
("png_sha256-sleutel '{path}' is geen .png", "png_sha256 key '{path}' is not a .png"),
387
("png_sha256 voor '{path}' is geen SHA-256 hex", "png_sha256 for '{path}' is not SHA-256 hex"),
388
("project.id mismatch", "project.id mismatch"),
389
("project.name mismatch", "project.name mismatch"),
390
("sex mismatch", "sex mismatch"),
391
("set mismatch", "set mismatch"),
392
("body_model sha256 mismatch", "body_model sha256 mismatch"),
393
("target mismatch", "target mismatch"),
394
("fps mismatch", "fps mismatch"),
395
("framing mismatch", "framing mismatch"),
396
("near/far mismatch", "near/far mismatch"),
397
("saved_maps mismatch", "saved_maps mismatch"),
398
("atlas.ppda naam/sha256 ongeldig", "invalid atlas.ppda name/sha256"),
399
("atlas.maps moet vier maps bevatten", "atlas.maps must contain four maps"),
400
("atlas mist {}", "atlas lacks {}"),
401
("atlas-bestandsnaam/sha256 ongeldig", "invalid atlas file name/sha256"),
402
("clips length mismatch", "clips length mismatch"),
403
("clip inventory mismatch", "clip inventory mismatch"),
404
("pivot_source mismatch", "pivot_source mismatch"),
405
("equipment preset sha256 mismatch", "equipment preset sha256 mismatch"),
406
("PNG-afmetingen buiten limiet", "PNG size outside the limit"),
407
("PNG header: {e}", "PNG header: {e}"),
408
("PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}", "PNG size {}x{} differs from target {expect_w}x{expect_h}"),
409
("PNG groter dan limiet", "PNG larger than the limit"),
410
("RGBA8-map heeft onverwacht kleurtype/bitdiepte", "RGBA8 map has an unexpected color type/bit depth"),
411
("PNG buffergrootte onbekend of te groot", "PNG buffer size unknown or too large"),
412
("PNG data: {e}", "PNG data: {e}"),
413
("PNG buffergrootte klopt niet", "PNG buffer size is wrong"),
414
("depth-map moet {} {}-bit zijn", "depth map must be {} {}-bit"),
415
("depth buffergrootte klopt niet", "depth buffer size is wrong"),
416
("transparante depth heeft niet-nul gray", "transparent depth has non-zero gray"),
417
("transparante normal heeft niet-nul RGB", "transparent normal has non-zero RGB"),
418
("normal lengte {len} buiten tolerantie", "normal length {len} out of tolerance"),
419
("specular RGB-kanalen zijn niet gelijk", "specular RGB channels are not equal"),
420
("runpad is geen map", "run path is not a folder"),
421
("complete.json ontbreekt en er is geen .ppda; de exportset is niet volledig", "complete.json is missing and there is no .ppda; the export set is not complete"),
422
(".ppda bevat geen exportgegevens en complete.json ontbreekt", ".ppda has no export data and complete.json is missing"),
423
("{name} ontbreekt", "{name} is missing"),
424
("{} groter dan {max} bytes", "{} larger than {max} bytes"),
425
("symlink geweigerd: {}", "symlink refused: {}"),
426
("geen regulier bestand: {}", "not a regular file: {}"),
427
("pad verlaat runmap: {}", "path leaves the run folder: {}"),
428
("render.json mist project", "render.json lacks project"),
429
("body sha", "body sha"),
430
("FBX bevat geen skinned mesh met driehoeken", "FBX contains no skinned mesh with triangles"),
431
("het FBX-pad is geen UTF-8", "the FBX path is not UTF-8"),
432
("FBX openen mislukt: {err}", "opening FBX failed: {err}"),
433
("FBX-bestand ontbreekt: {}", "FBX file missing: {}"),
434
("FBX-bestand is groter dan {MAX_BYTES} bytes ({})", "FBX file is larger than {MAX_BYTES} bytes ({})"),
435
("FBX laden mislukt ({}): {}", "loading FBX failed ({}): {}"),
436
("FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters.", "FBX normalized with {FBX_BACKEND}; axes right-handed Y-up, units meters."),
437
("Dezelfde genormaliseerde scene voedt de live viewport en de productierender.", "The same normalized scene feeds the live viewport and the production render."),
438
("animatiegegevens waren gemarkeerd als genegeerd door de loader", "animation data was marked as ignored by the loader"),
439
("skinned mesh heeft geen skin deformer", "skinned mesh has no skin deformer"),
440
("niet-ondersteunde FBX-skinningmethode {:?}; alleen linear/rigid", "unsupported FBX skinning method {:?}; only linear/rigid"),
441
("FBX-skin heeft geen clusters", "FBX skin has no clusters"),
442
("FBX-skeleton heeft {} joints; limiet is {MAX_JOINTS}", "FBX skeleton has {} joints; the limit is {MAX_JOINTS}"),
443
("{} blend deformers genegeerd (morph targets niet ondersteund)", "{} blend deformers ignored (morph targets not supported)"),
444
("FBX-mesh te groot ({} vertices, {} indices)", "FBX mesh too large ({} vertices, {} indices)"),
445
("geen animatiestacks met transformkanalen gevonden", "no animation stacks with transform channels found"),
446
("right-handed Y-up", "right-handed Y-up"),
447
("node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd", "node '{}' is a geometry-transform helper that was not removed; this dialect is not silently accepted"),
448
("node '{}' heeft niet-eindige lokale transform", "node '{}' has a non-finite local transform"),
449
("ufbx typed_id {} komt niet overeen met node-index {index}", "ufbx typed_id {} does not match node index {index}"),
450
("skin cluster zonder bone_node", "skin cluster without bone_node"),
451
("skin cluster bone heeft geen naam", "skin cluster bone has no name"),
452
("inverse-bind van '{name}' is niet eindig", "inverse bind of '{name}' is not finite"),
453
("materiaal '{}' heeft niet-eindige basiskleur", "material '{}' has a non-finite base color"),
454
("ingebedde texture '{name}' kon niet gelezen worden: {err}", "embedded texture '{name}' could not be read: {err}"),
455
("texture '{name}' is groter dan 2048×2048", "texture '{name}' is larger than 2048×2048"),
456
("texture '{filename}' lezen mislukt: {err}", "reading texture '{filename}' failed: {err}"),
457
("texture '{filename}' is geen geldige 8-bit PNG: {err}", "texture '{filename}' is not a valid 8-bit PNG: {err}"),
458
("texture '{filename}' is groter dan 2048×2048", "texture '{filename}' is larger than 2048×2048"),
459
("FBX-texture ontbreekt: {filename}", "FBX texture missing: {filename}"),
460
("texture-referentie '{filename}' zonder bestand genegeerd", "texture reference '{filename}' without a file ignored"),
461
("onveilig texturepad geweigerd: {relative}", "unsafe texture path refused: {relative}"),
462
("textureafmeting ongeldig of >2048", "texture size invalid or >2048"),
463
("texturebuffer te groot", "texture buffer too large"),
464
("niet-ondersteund PNG-kleurtype {other:?}", "unsupported PNG color type {other:?}"),
465
("materiaalpart zonder seed", "material part without seed"),
466
("FBX-mesh heeft geen tekenbare driehoeken na triangulatie", "FBX mesh has no drawable triangles after triangulation"),
467
("skin vertex-index {vertex} buiten bereik", "skin vertex index {vertex} out of range"),
468
("skin weight verwijst naar onbekende cluster {}", "skin weight refers to unknown cluster {}"),
469
("skin weight is niet eindig of negatief", "skin weight is not finite or negative"),
470
("skin weights sommeren tot nul", "skin weights sum to zero"),
471
("animatiestack '{}' bakken mislukt: {}", "baking animation stack '{}' failed: {}"),
472
("gebakken node typed_id {node} buiten bereik ({} nodes)", "baked node typed_id {node} out of range ({} nodes)"),
473
("gebakken vec3-kanaal heeft niet-eindige waarden", "baked vec3 channel has non-finite values"),
474
("gebakken rotatiekanaal heeft niet-eindige waarden", "baked rotation channel has non-finite values"),
475
("het projectpad is leeg of bevat een leeg teken", "the project path is empty or contains a NUL character"),
476
("open een .ppd-bestand of een projectmap met character.ppd", "open a .ppd file or a project folder with character.ppd"),
477
("geen projectbestand op {}", "no project file at {}"),
478
("Content Type", "Content Type"),
479
("HOME of XDG_DATA_HOME ontbreekt", "HOME or XDG_DATA_HOME missing"),
480
("{command}: {e}", "{command}: {e}"),
481
("{command} is mislukt", "{command} failed"),
482
("het uitvoerbare pad moet UTF-8 zijn", "the executable path must be UTF-8"),
483
("kies een absoluut pad zonder stuurtekens", "choose an absolute path without control characters"),
484
("[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nStartupWMClass=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n", "[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nStartupWMClass=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n"),
485
("OBJ + MTL + textures", "OBJ + MTL + textures"),
486
("FBX drawable scene", "FBX drawable scene"),
487
("niet gebouwd", "not built"),
488
("Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 60 hoeken (5 pitches × 12 yaws) met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie, plus per maptype een atlas en een .ppda. De 2D-preview leest exports via .ppda en atlassen en composeert ze met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening.", "This build reads glTF/GLB, FBX and OBJ+MTL, opens a .ppd project and shows a live skinned 3D preview of glTF/GLB and FBX bodies (FBX via a once-normalized drawable/animatable scene) with all their clips (such as walk and attack), rigid bone-attached OBJ or FBX equipment and binary .ppe presets. Render produces, for glTF/GLB and FBX bodies, all clips × frames × 60 angles (5 pitches × 12 yaws) with a fixed orthographic camera and diffuse/depth/normal/specular at target resolution, plus one atlas per map type and a .ppda. The 2D preview reads exports through .ppda and atlases and composes them with depth ordering, looping playback, angle navigation and continuous light control."),
489
("name: Paperdoll Renderer", "name: Paperdoll Renderer"),
490
("phase: project-editor", "phase: project-editor"),
491
("het venster kan niet openen; deze bibliotheken ontbreken: {}.", "the window cannot open; these libraries are missing: {}."),
492
("Paperdoll Renderer\n\nGebruik:\n paperdoll-renderer startscherm\n paperdoll-renderer pad/naar/character.ppd open dat project\n paperdoll-renderer --install-desktop registreer Openen met op Linux\n paperdoll-renderer --status print de status en sluit\n paperdoll-renderer --help toon deze tekst\n paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam\n\n--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd.", "Paperdoll Renderer\n\nUsage:\n paperdoll-renderer start screen\n paperdoll-renderer path/to/character.ppd open that project\n paperdoll-renderer --install-desktop register Open with on Linux\n paperdoll-renderer --status print the status and exit\n paperdoll-renderer --help show this text\n paperdoll-renderer --preview-fixture open a sample project on the male body\n\n--status stands alone. A .ppd path or a project folder opens character.ppd."),
493
("geef één projectpad", "give one project path"),
494
("--status staat alleen en neemt geen projectpad aan", "--status stands alone and takes no project path"),
495
("--preview-fixture opent het voorbeeld en neemt geen projectpad aan", "--preview-fixture opens the sample and takes no project path"),
496
("application icon png", "application icon png"),
497
("png has no frame", "png has no frame"),
498
("icon must decode to 8-bit rgba, got {:?} {:?}", "icon must decode to 8-bit rgba, got {:?} {:?}"),
499
("icon frame size does not match rgba pixels", "icon frame size does not match rgba pixels"),
500
("ufbx 0.11.5, meegecompileerd uit bron (geen losse libassimp)", "ufbx 0.11.5, compiled in from source (no separate libassimp)"),
501
("het modelbestand ontbreekt: {path}", "the model file is missing: {path}"),
502
("importeren van {path} is mislukt: {detail}", "importing {path} failed: {detail}"),
503
("het bestand is groter dan {MAX_BYTES} bytes", "the file is larger than {MAX_BYTES} bytes"),
504
("bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf, .fbx of .obj.", "file type '{other}' is not read. Use .glb, .gltf, .fbx or .obj."),
505
("mesh '{}' heeft een niet-eindige positie", "mesh '{}' has a non-finite position"),
506
("mesh '{}' heeft geen driehoeken", "mesh '{}' has no triangles"),
507
("een skin-weight is niet eindig of is negatief", "a skin weight is not finite or is negative"),
508
("het aantal inverse-bindmatrices wijkt af van het aantal joints", "the number of inverse bind matrices differs from the number of joints"),
509
("een animatiesample is niet eindig", "an animation sample is not finite"),
510
("clip {index} heeft geen naam", "clip {index} has no name"),
511
("dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan.", "this glTF file has no skeleton. Bone and clip features stay off until the file contains skins."),
512
("Lcl Translation", "Lcl Translation"),
513
("Lcl Rotation", "Lcl Rotation"),
514
("Lcl Scaling", "Lcl Scaling"),
515
("het FBX-bestand bevat geen mesh", "the FBX file contains no mesh"),
516
("FBX gelezen met {FBX_BACKEND}. Importoverzicht toont meshes/joints/clips; genormaliseerde drawable scene via fbx_scene. Live viewport en productie gebruiken nog glTF/GLB.", "FBX read with {FBX_BACKEND}. The import overview shows meshes/joints/clips; normalized drawable scene via fbx_scene. Live viewport and production still use glTF/GLB."),
517
("de FBX-texture ontbreekt", "the FBX texture is missing"),
518
("MTL ontbreekt of is onleesbaar: {err}", "MTL missing or unreadable: {err}"),
519
("mesh '{}' heeft een ongeldige positie", "mesh '{}' has an invalid position"),
520
("mesh '{}' is niet in driehoeken", "mesh '{}' is not triangulated"),
521
("OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips.", "OBJ is a static mesh for equipment. This format has no skeleton and provides no skeletal animation clips."),
522
("de texture uit de MTL ontbreekt", "the texture from the MTL is missing"),
523
("texturepad '{value}' wijst buiten de projectmap", "texture path '{value}' points outside the project folder"),
524
("glTF-verwijzing '{uri}' wordt niet gevolgd", "glTF reference '{uri}' is not followed"),
525
("dit is geen GLB", "this is not a GLB"),
526
("de GLB heeft geen JSON-chunk", "the GLB has no JSON chunk"),
527
("het pad '{label}' wijst buiten de projectmap", "the path '{label}' points outside the project folder"),
528
("kanaal zonder keyframes", "channel without keyframes"),
529
("keyframetijden niet eindig of niet oplopend", "keyframe times not finite or not increasing"),
530
("{} waarden voor {} keyframes (verwacht {per_key} per key)", "{} values for {} keyframes (expected {per_key} per key)"),
531
("niet-eindige keyframewaarde", "non-finite keyframe value"),
532
("FBX zonder skin: statische rustpose, geen skeleton of animaties", "FBX without skin: static rest pose, no skeleton or animations"),
533
("OBJ heeft geen skeleton of animaties: statische pose", "OBJ has no skeleton or animations: static pose"),
534
("{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj", "{} is not a drawable body; use .glb, .gltf, .fbx or .obj"),
535
("statisch {format}-lichaam: {error}", "static {format} body: {error}"),
536
("statisch lichaam: ongeldige driehoeksindices", "static body: invalid triangle indices"),
537
("statisch lichaam: niet-eindige posities of normalen", "static body: non-finite positions or normals"),
538
("statisch lichaam heeft te veel vertices", "static body has too many vertices"),
539
("statisch {format}-lichaam heeft geen tekenbare driehoeken", "static {format} body has no drawable triangles"),
540
("ongeldig glTF-bestand (parser weigerde het document)", "invalid glTF file (the parser refused the document)"),
541
("het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}", "the skeleton has {} joints; the preview stops at {MAX_JOINTS}"),
542
("inverse-bindmatrices en joints verschillen", "inverse bind matrices and joints differ"),
543
("glTF-nodehiërarchie bevat een cyclus", "glTF node hierarchy contains a cycle"),
544
("singuliere joint-rustmatrix", "singular joint rest matrix"),
545
("skinjoint buiten skin", "skin joint outside the skin"),
546
("glTF-node heeft een singuliere transformatie", "glTF node has a singular transform"),
547
("het glTF-bestand heeft geen tekenbare driehoeken", "the glTF file has no drawable triangles"),
548
("glTF-mesh heeft niet-eindige posities na de node-transformatie", "glTF mesh has non-finite positions after the node transform"),
549
("node {target}: geen uitvoerwaarden", "node {target}: no output values"),
550
("node {target}: morph-target-gewichten (niet ondersteund)", "node {target}: morph target weights (not supported)"),
551
("animatie '{}': ongeldig kanaal op node {target}: {reason}", "animation '{}': invalid channel on node {target}: {reason}"),
552
("animatie {}", "animation {}"),
553
("live/productie ondersteunt alleen driehoekprimitieven", "live/production supports only triangle primitives"),
554
("glTF-primitive heeft niet-eindige posities", "glTF primitive has non-finite positions"),
555
("glTF-primitive heeft ongeldige normalen", "glTF primitive has invalid normals"),
556
("glTF-primitive heeft ongeldige joints/gewichten", "glTF primitive has invalid joints/weights"),
557
("glTF-primitive heeft indices buiten het vertexbereik", "glTF primitive has indices outside the vertex range"),
558
("ontbrekende bodytexture", "missing body texture"),
559
("bodytexture moet 8-bit zijn", "body texture must be 8-bit"),
560
("bodytexture groter dan 2048×2048", "body texture larger than 2048×2048"),
561
("ongeldige glTF-node", "invalid glTF node"),
562
("glTF-node heeft een niet-eindige transformatie", "glTF node has a non-finite transform"),
563
("glTF-primitive heeft een onvolledige driehoek", "glTF primitive has an incomplete triangle"),
564
("glTF zonder skin: statische pose, geen skeleton of animaties", "glTF without skin: static pose, no skeleton or animations"),
565
("glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)", "glTF without skin: static pose; {animations} animation(s) not applied (no skeleton)"),
566
("Alleen diffuse", "Diffuse only"),
567
("Alleen normal map", "Normal map only"),
568
("Alleen depth map", "Depth map only"),
569
("Alleen specular map", "Specular map only"),
570
("oude run-map uit een eerdere versie; genegeerd (opnieuw renderen naar <sekse>-<settype>-atlas)", "old run folder from an earlier version; ignored (render again to <sekse>-<settype>-atlas)"),
571
("mapnaam past niet bij inhoud ({})", "folder name does not match its content ({})"),
572
("ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}", "missing maps for set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}"),
573
("geen body-exportset voor dit geslacht", "no body export set for this sex"),
574
("geen voltooide exportset voor dit equipment", "no completed export set for this equipment"),
575
("geen voltooide body-export voor dit geslacht", "no completed body export for this sex"),
576
("Geen animaties: statisch lichaam zonder skeleton (bindpose)", "No animations: static body without skeleton (bind pose)"),
577
("Geen animaties: bindpose", "No animations: bind pose"),
578
("pitch {pitch:.0}", "pitch {pitch:.0}"),
579
("yaw {:.0}° / {} pitch {:.0}°", "yaw {:.0}° / {} pitch {:.0}°"),
580
("Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws.", "Drag to rotate, scroll to zoom. Yaw 0 looks at the front (+Z, glTF standard). The buttons set the five pitches and the twelve yaws."),
581
("Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture.", "No body loaded. Open a project with a .glb/.gltf/.fbx/.obj body or start with --preview-fixture."),
582
("preview gpu lock", "preview gpu lock"),
583
("Vast renderkader", "Fixed render frame"),
584
("native depth attachment ontbreekt: {native_bits} bits", "native depth attachment missing: {native_bits} bits"),
585
("onvolledig offscreen depth-target", "incomplete offscreen depth target"),
586
("achterste vlak overschrijft voorste: {near:?}", "back face overwrites front: {near:?}"),
587
("depth niet gewist voor volgende frame: {next:?}", "depth not cleared for the next frame: {next:?}"),
588
("draw-order afhankelijke occlusie: {near:?} / {reverse:?}", "draw-order dependent occlusion: {near:?} / {reverse:?}"),
589
("equipment wist body-depth: {hidden:?}", "equipment clears body depth: {hidden:?}"),
590
("voorste equipmenttexture ontbreekt: {textured:?}", "front equipment texture missing: {textured:?}"),
591
("alpha-cutout bedekt body: {cutout:?}", "alpha cutout covers body: {cutout:?}"),
592
("shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}", "shiny map does not affect specular correctly: {shiny:?}/{matte:?}"),
593
("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}"),
594
("target-res / diffuse", "target-res / diffuse"),
595
("fps moet 1–240 zijn", "fps must be 1–240"),
596
("ongeldige of te lange animatie", "invalid or too long animation"),
597
("te veel frames", "too many frames"),
598
("te veel beelden", "too many images"),
599
("te veel passes", "too many passes"),
600
("te veel bestanden", "too many files"),
601
("batch groter dan 500000 uitvoerbestanden", "batch larger than 500000 output files"),
602
("ongeldig uitvoerpad/token", "invalid output path/token"),
603
("ongeldige skinjoint", "invalid skin joint"),
604
("ongeldige GPU-scène/afmetingen", "invalid GPU scene/size"),
605
("ongeldige of te grote GPU-geometrie", "invalid or too large GPU geometry"),
606
("ongeldige GPU-texture", "invalid GPU texture"),
607
("productie-FBO onvolledig", "production FBO incomplete"),
608
("highres moet 1–2048 pixels zijn (8× target)", "highres must be 1–2048 pixels (8× target)"),
609
("shader ontbreekt", "shader missing"),
610
("ongeldige GPU-geometrie", "invalid GPU geometry"),
611
("productie-GL fout 0x{error:x}", "production GL error 0x{error:x}"),
612
("te veel pixels", "too many pixels"),
613
("ongeldige GPU-mapafmetingen/data", "invalid GPU map size/data"),
614
("ongeldige target-afmetingen", "invalid target size"),
615
("ongeldige 8× resolve-afmetingen", "invalid 8× resolve size"),
616
("ongeldige depth/coverage in GPU-resultaat", "invalid depth/coverage in GPU result"),
617
("ongeldig uitvoerpad", "invalid output path"),
618
("uitvoermap ontbreekt", "output folder missing"),
619
("uitvoer verlaat projectmap", "output leaves the project folder"),
620
("onveilig uitvoerpad", "unsafe output path"),
621
("ongeldige PNG-afmetingen", "invalid PNG size"),
622
("ongeldige 16-bit depthafmetingen", "invalid 16-bit depth size"),
623
("ongeldige depthafmetingen", "invalid depth size"),
624
("8× highres vereist targetafmetingen 1–256", "8× highres requires target size 1–256"),
625
("statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen", "static body without skeleton: equipment cannot be attached to a bone"),
626
("equipment niet geldig voor dit geslacht", "equipment not valid for this sex"),
627
("equipment heeft geen onderdelen", "equipment has no parts"),
628
("batch groter dan 16 GiB ongecomprimeerde targetmaps", "batch larger than 16 GiB of uncompressed target maps"),
629
("project.id ongeldig voor export-identiteit", "project.id invalid for the export identity"),
630
("project.name te lang voor export-identiteit", "project.name too long for the export identity"),
631
("body_model.sha256 ongeldig", "invalid body_model.sha256"),
632
("equipment fingerprint past niet bij de job", "equipment fingerprint does not match the job"),
633
("clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained", "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained"),
634
("straight, opaque/cutout, coverage resolve", "straight, opaque/cutout, coverage resolve"),
635
("unlit sRGB albedo × vertex color, linear-light detail resolve", "unlit sRGB albedo × vertex color, linear-light detail resolve"),
636
("linear scalar; body defaults zero", "linear scalar; body defaults zero"),
637
("Herstel: {note}", "Recovery: {note}"),
638
("Oude onvoltooide render verwijderd: {name}", "Old unfinished render removed: {name}"),
639
("Controle bestaande uitvoer: {done}/{all} PNG's", "Checking existing output: {done}/{all} PNGs"),
640
("Let op: clip '{}': kanaal niet toegepast: {reason}", "Note: clip '{}': channel not applied: {reason}"),
641
("{} {set} · {} f{frame}/{} · p{pitch} y{yaw}", "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}"),
642
("{pose} · al aanwezig, checksums kloppen", "{pose} · already present, checksums match"),
643
("{pose} · highres", "{pose} · highres"),
644
("GPU-service gesloten", "GPU service closed"),
645
("GPU-service gestopt", "GPU service stopped"),
646
("Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)", "Warning: {pose} · {clamped} highres pixels outside depth range {:.2}–{:.2} m (clamped)"),
647
("{pose} · detail", "{pose} · detail"),
648
("{pose} · target-res / diffuse", "{pose} · target-res / diffuse"),
649
("{pose} · {map}", "{pose} · {map}"),
650
("{pose} · {pass} (bestaand bestand behouden)", "{pose} · {pass} (existing file kept)"),
651
("{pose} · {pass}", "{pose} · {pass}"),
652
("checksumtelling {} wijkt af van geplande {} PNGs", "checksum count {} differs from planned {} PNGs"),
653
("rendered this attempt; kept files from a resumed run are not re-counted", "rendered this attempt; kept files from a resumed run are not re-counted"),
654
("Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)", "Warning: depth clamped in {clamped_views} images ({clamped_pixels} highres pixels outside the fixed range)"),
655
("voltooide render faalt controle: {e}", "completed render fails verification: {e}"),
656
("GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}", "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}"),
657
("GPU map winner depends on submission order", "GPU map winner depends on submission order"),
658
("missing probe program", "missing probe program"),
659
("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}", "diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"),
660
("target map dimensions/normals invalid", "target map dimensions/normals invalid"),
661
("flat normals are not geometry-derived: {:?}", "flat normals are not geometry-derived: {:?}"),
662
("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives", "Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives"),
663
("renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m, dieptebereik 0,1–19 m", "render frame: height 0.1–10 m, center/pivot -10–10 m, depth range 0.1–19 m"),
664
("dit project gebruikt format_version {found}; deze build leest alleen versie {supported}", "this project uses format_version {found}; this build reads only version {supported}"),
665
("het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven.", "the project file has a field that version {FORMAT_VERSION} does not know ({detail}). The file was not rewritten."),
666
("het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}", "the project file is not a valid version {FORMAT_VERSION}: {detail}"),
667
("{message} ({path})", "{message} ({path})"),
668
("dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}", "this project uses format_version {}; this build reads only version {FORMAT_VERSION}"),
669
("het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'", "the project id must be 1 to 64 characters: letters, digits, '-' or '_'"),
670
("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens", "the project name must be 1 to 80 characters, without control characters"),
671
("het mannelijke model", "the male model"),
672
("het vrouwelijke model", "the female model"),
673
("maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project", "at most {MAX_PROJECT_PRESETS} equipment presets per project"),
674
("de preset '{preset}' is niet genormaliseerd", "the preset '{preset}' is not normalized"),
675
("de preset '{clean}' staat dubbel in het project", "the preset '{clean}' appears twice in the project"),
676
("een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam", "an equipment preset may not be called 'body'; that is reserved for the body"),
677
("character.ppd is het projectbestand, geen preset", "character.ppd is the project file, not a preset"),
678
("preset '{preset}': {message}", "preset '{preset}': {message}"),
679
("de uitvoermap is niet genormaliseerd", "the output folder is not normalized"),
680
("de uitvoermap moet een submap zijn, niet de projectmap zelf", "the output folder must be a subfolder, not the project folder itself"),
681
("de uitvoermap mag niet het .ppd-bestand zijn", "the output folder may not be the .ppd file"),
682
("de uitvoermap '{clean}' mag de bron '{source}' niet bevatten", "the output folder '{clean}' may not contain the source '{source}'"),
683
("uitvoermap: {message}", "output folder: {message}"),
684
("de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}", "the estimated render buffer is {bytes} bytes; the maximum in this version is {MAX_ESTIMATE_BYTES}"),
685
("breedte en hoogte lopen buiten de geheugenschatting van deze versie", "width and height exceed the memory estimate of this version"),
686
("breedte en hoogte moeten groter zijn dan 0", "width and height must be greater than 0"),
687
("breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn", "width and height may be at most {MAX_DIMENSION}"),
688
("fps moet groter zijn dan 0", "fps must be greater than 0"),
689
("fps mag hoogstens {MAX_FPS} zijn", "fps may be at most {MAX_FPS}"),
690
("de wortel moet een object zijn", "the root must be an object"),
691
("format_version ontbreekt", "format_version missing"),
692
("format_version moet een geheel getal zijn", "format_version must be an integer"),
693
("unknown field", "unknown field"),
694
("geef het project een naam", "give the project a name"),
695
("de projectnaam is te lang (maximaal 100 tekens)", "the project name is too long (at most 100 characters)"),
696
("de projectnaam mag geen / \\ < > : \" | ? * of stuurtekens bevatten", "the project name may not contain / \\ < > : \" | ? * or control characters"),
697
("de projectnaam mag niet op een punt eindigen", "the project name may not end in a dot"),
698
("kies eerst een bestaande map waarin het project komt", "choose an existing folder for the project first"),
699
("de map '{name}' bestaat al en is niet leeg; kies een andere naam", "the folder '{name}' already exists and is not empty; choose another name"),
700
("er bestaat al een bestand '{name}'; kies een andere naam", "a file '{name}' already exists; choose another name"),
701
("de projectmap is een bestand", "the project folder is a file"),
702
("er staat al een character.ppd; open dat project in plaats van het te overschrijven", "there is already a character.ppd; open that project instead of overwriting it"),
703
("kies een .ppd-bestand", "choose a .ppd file"),
704
("openen is mislukt: {err}. Er is niets gewijzigd", "opening failed: {err}. Nothing was changed"),
705
("kies een regulier .ppd-bestand", "choose a regular .ppd file"),
706
("projectmanifest groter dan 4 MiB", "project manifest larger than 4 MiB"),
707
("het .ppd-bestand heeft geen projectmap", "the .ppd file has no project folder"),
708
("{label} mag niet character.ppd zijn", "{label} may not be character.ppd"),
709
("{label} is niet genormaliseerd: '{value}'", "{label} is not normalized: '{value}'"),
710
("{label}: {message}", "{label}: {message}"),
711
("het pad is leeg", "the path is empty"),
712
("gebruik '/' in projectpaden", "use '/' in project paths"),
713
("een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap", "an absolute path does not belong in the .ppd file; choose a path inside the project folder"),
714
("het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben", "the path may not point outside the project folder and may not have empty segments"),
715
("het pad bevat een leeg teken", "the path contains a NUL character"),
716
("de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties", "the preset name '{stem}' must be lowercase letters, digits, '-' or '_', without spaces"),
717
("de uitvoermap '{}' is een bestand", "the output folder '{}' is a file"),
718
("de uitvoermap '{}' bestaat nog niet", "the output folder '{}' does not exist yet"),
719
("het bestand ontbreekt in de projectmap: {relative}", "the file is missing from the project folder: {relative}"),
720
("het pad '{relative}' wijst buiten de projectmap", "the path '{relative}' points outside the project folder"),
721
("de uitvoermap '{relative}' is een bestand", "the output folder '{relative}' is a file"),
722
("het pad wijst buiten de projectmap", "the path points outside the project folder"),
723
("het .ppd-bestand heeft geen map", "the .ppd file has no folder"),
724
("het .ppd-bestand heeft geen naam", "the .ppd file has no name"),
725
("opslaan is mislukt: {err}. Het vorige bestand is blijven staan", "saving failed: {err}. The previous file was kept"),
726
("Geannuleerd; gedeeltelijke uitvoer behouden", "Cancelled; partial output kept"),
727
("raw files", "raw files"),
728
("opschonen geweigerd: render is niet voltooid", "cleanup refused: render is not complete"),
729
("opschonen geweigerd: run.lock is bezet", "cleanup refused: run.lock is in use"),
730
("opschonen {name}: {e}", "cleanup {name}: {e}"),
731
("opschonen complete.json: {e}", "cleanup complete.json: {e}"),
732
("run.lock is geen regulier bestand", "run.lock is not a regular file"),
733
("deze run wordt op dit moment al gerenderd (run.lock bezet)", "this run is already being rendered (run.lock in use)"),
734
("run.lock: {e}", "run.lock: {e}"),
735
("progress.jsonl is geen regulier bestand", "progress.jsonl is not a regular file"),
736
("{} is geen regulier bestand", "{} is not a regular file"),
737
("quarantaine van {rel} mislukt: {e}", "quarantining {rel} failed: {e}"),
738
("geen vrije quarantainenaam voor {rel}", "no free quarantine name for {rel}"),
739
("archiveren van {name} mislukt: {e}", "archiving {name} failed: {e}"),
740
("geen vrije archiefnaam voor {name}", "no free archive name for {name}"),
741
("render.json ontbreekt", "render.json missing"),
742
("huidige instellingen/bronnen passen niet bij deze run (verschil in: {})", "current settings/sources do not match this run (differences in: {})"),
743
("runmap is geen gewone map", "run folder is not a plain folder"),
744
("runmap ligt niet direct in de uitvoermap", "run folder is not directly inside the output folder"),
745
("deze run is al voltooid (complete.json bestaat)", "this run is already complete (complete.json exists)"),
746
("journal noemt een onbekend pad: {unknown}", "journal names an unknown path: {unknown}"),
747
("map staat op de plaats van PNG {rel}", "folder in the place of PNG {rel}"),
748
("wordt op dit moment gerenderd", "is being rendered right now"),
749
("past niet meer bij de huidige instellingen/bronnen (verschil in: {})", "no longer matches the current settings/sources (differences in: {})"),
750
("oude onvoltooide render {name}: {e}", "old unfinished render {name}: {e}"),
751
("oude resultaatmap opzijzetten: {e}", "moving the old result folder aside: {e}"),
752
("nieuwe resultaatmap plaatsen: {e}", "placing the new result folder: {e}"),
753
("vorige resultaatmap {final_name} hersteld", "previous result folder {final_name} restored"),
754
("voltooide render {name} alsnog geplaatst als {final_name}", "completed render {name} placed as {final_name} after all"),
755
("Laad een project of maak een nieuwe projectmap.", "Load a project or create a new project folder."),
756
("maximaal 64 equipmentpresets per project", "at most 64 equipment presets per project"),
757
("equipmentpresets samen groter dan 64 MiB", "equipment presets together larger than 64 MiB"),
758
("ongeldige equipmentnaam", "invalid equipment name"),
759
("de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'", "the name uses lowercase letters, digits, '-' or '_', and is not 'body'"),
760
("die equipmentnaam bestaat al", "that equipment name already exists"),
761
("Projectmap aangemaakt, met de testlichamen in assets/body.", "Project folder created, with the test bodies in assets/body."),
762
("Project aangemaakt, met de testlichamen in assets/body.", "Project created, with the test bodies in assets/body."),
763
("Project geopend.", "Project opened."),
764
("er is geen project om op te slaan", "there is no project to save"),
765
("ongeldig projectpad", "invalid project path"),
766
("het bestand ligt buiten de projectmap", "the file is outside the project folder"),
767
("kies een regulier bestand", "choose a regular file"),
768
("presetbestand overschrijdt het bestands-/projectbudget", "preset file exceeds the file/project budget"),
769
("legacy preset; kies een OBJ- of FBX-model om te configureren", "legacy preset; choose an OBJ or FBX model to configure"),
770
("dubbele equipmentnaam", "duplicate equipment name"),
771
("equipment '{}' heeft geen geldige naam", "equipment '{}' has no valid name"),
772
("legacy preset groter dan 64 KiB", "legacy preset larger than 64 KiB"),
773
("de afbeelding heeft geen pixels", "the image has no pixels"),
774
("het lichaam heeft geen mesh", "the body has no mesh"),
775
("Aanmaken", "Create"),
776
("Animatie", "Animation"),
777
("Annuleren", "Cancel"),
778
("Breedte", "Width"),
779
("Depth-map", "Depth map"),
780
("Equipment-configuratie", "Equipment configuration"),
781
("Geslacht", "Sex"),
782
("Glanssterkte", "Gloss strength"),
783
("Grijs", "Gray"),
784
("Hervat", "Resume"),
785
("Hoogte", "Height"),
786
("Lichaamsmodel", "Body model"),
787
("Man", "Male"),
788
("Map", "Folder"),
789
("Naam", "Name"),
790
("Nieuw", "New"),
791
("Opgeslagen.", "Saved."),
792
("Opslaan", "Save"),
793
("Paperdoll-project", "Paperdoll project"),
794
("Pauze", "Pause"),
795
("RGB-banden", "RGB bands"),
796
("Resultaat", "Result"),
797
("Shinymap", "Shiny map"),
798
("Snelheid", "Speed"),
799
("Speel", "Play"),
800
("Terug", "Back"),
801
("Vrouw", "Female"),
802
("Weergave", "View"),
803
("Wijzigingen", "Changes"),
804
("achterkant", "back"),
805
("linkerzijde", "left side"),
806
("rechterzijde", "right side"),
807
("voorkant", "front"),
808
("automatisch", "automatic"),
809
("doelresolutie", "target resolution"),
810
("man", "male"),
811
("vrouw", "female"),
812
("nieuw", "new"),
813
("Instellingen", "Settings"),
814
("Taal", "Language"),
815
("Sluiten", "Close"),
816
];
@@ -0,0 +1,816 @@1
//! Generated translation table (Dutch source text → English). Each entry is a2
//! source string or format template; `{…}` holes are matched at runtime.3
//! Regenerate entries when UI/error text changes; `i18n` tests enforce coverage.4
pub const TABLE: &[(&str, &str)] = &[5
("glTF JSON: {e}", "glTF JSON: {e}"),6
("mtllib ", "mtllib "),7
("bump ", "bump "),8
("norm ", "norm "),9
("kies een bestand", "choose a file"),10
("bijbehorende bestanden ontbreken naast het model: {}", "associated files are missing next to the model: {}"),11
("de bestandsnaam is niet bruikbaar", "the file name is not usable"),12
("{rel}: {e}", "{rel}: {e}"),13
("{rel}: geen gewoon bestand of te groot", "{rel}: not a regular file or too large"),14
("ongeldig pad", "invalid path"),15
("bestand te groot", "file too large"),16
("geen geldige GLB", "not a valid GLB"),17
("GLB JSON-chunk afgekapt", "GLB JSON chunk truncated"),18
("GLB JSON: {e}", "GLB JSON: {e}"),19
("verwijzing '{rel}' ligt buiten de modelmap; kopieer de bestanden naast het model", "reference '{rel}' points outside the model folder; copy the files next to the model"),20
("atlascel moet 1–256 pixels per as zijn", "atlas cell must be 1–256 pixels per axis"),21
("atlas zonder rijen", "atlas without rows"),22
("atlas zou {width}×{height} pixels worden ({columns} hoeken × {rows} frames); maximaal {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Verlaag fps, targetresolutie of animatieduur — de atlas wordt niet gesplitst", "the atlas would be {width}×{height} pixels ({columns} angles × {rows} frames); maximum {MAX_ATLAS_DIM}×{MAX_ATLAS_DIM}. Lower fps, target resolution or animation length — the atlas is never split"),23
("te veel frames voor de atlas", "too many frames for the atlas"),24
("ongeldig PNG-pad", "invalid PNG path"),25
("atlas: {rel} ontbreekt in de checksums", "atlas: {rel} is missing from the checksums"),26
("atlas: checksum van {rel} klopt niet meer", "atlas: checksum of {rel} no longer matches"),27
("atlas: {rel}: {e}", "atlas: {rel}: {e}"),28
("atlas: onverwachte depth", "atlas: unexpected depth"),29
("atlas {} · rij {}/{}", "atlas {} · row {}/{}"),30
("atlas-digest ontbreekt", "atlas digest missing"),31
("render.json mist {p}", "render.json lacks {p}"),32
("{p} is geen tekst", "{p} is not text"),33
("atlas: niet alle losse maps zijn geverifieerd", "atlas: not all loose maps have been verified"),34
("atlas · {name}", "atlas · {name}"),35
(".ppda: depth_bits past niet bij format_version", ".ppda: depth_bits does not match format_version"),36
(".ppda: {e}", ".ppda: {e}"),37
(".ppda format_version {} wordt niet ondersteund (verwacht {PPDA_FORMAT_VERSION}; versie 1 = oud globaal dieptebereik)", ".ppda format_version {} is not supported (expected {PPDA_FORMAT_VERSION}; version 1 = old global depth range)"),38
(".ppda: export past niet bij format_version", ".ppda: export does not match format_version"),39
(".ppda: exportgegevens passen niet bij de set", ".ppda: export data does not match the set"),40
(".ppda: ongeldig geslacht", ".ppda: invalid sex"),41
(".ppda: ongeldig settype", ".ppda: invalid set type"),42
(".ppda-bestandsnaam '{file_name}' past niet bij {}/{}", ".ppda file name '{file_name}' does not match {}/{}"),43
(".ppda: project id/naam ongeldig", ".ppda: invalid project id/name"),44
(".ppda: body_model_sha256 ongeldig", ".ppda: invalid body_model_sha256"),45
(".ppda: equipment_preset_sha256 past niet bij settype", ".ppda: equipment_preset_sha256 does not match the set type"),46
(".ppda: onbekende pivot_source", ".ppda: unknown pivot_source"),47
(".ppda: camera-/dieptecontract wijkt af", ".ppda: camera/depth contract differs"),48
(".ppda: ongeldige fps", ".ppda: invalid fps"),49
(".ppda: pitches/yaws wijken af van −60…+60 × 0…330", ".ppda: pitches/yaws differ from −60…+60 × 0…330"),50
(".ppda: clip-inventaris ongeldig", ".ppda: invalid clip inventory"),51
(".ppda: te veel frames", ".ppda: too many frames"),52
(".ppda: clips niet in bronvolgorde", ".ppda: clips not in source order"),53
(".ppda: ongeldige clip-token '{}'", ".ppda: invalid clip token '{}'"),54
(".ppda: clipnaam/duur ongeldig", ".ppda: invalid clip name/duration"),55
(".ppda: frame_count past niet bij frames", ".ppda: frame_count does not match frames"),56
(".ppda: frame-index of aantal hoeken ongeldig", ".ppda: invalid frame index or number of angles"),57
(".ppda: celcoördinaten van {} f{k} p{} y{} kloppen niet", ".ppda: cell coordinates of {} f{k} p{} y{} are wrong"),58
(".ppda: maps moet precies diffuse/normal/depth/specular bevatten", ".ppda: maps must contain exactly diffuse/normal/depth/specular"),59
(".ppda mist map {}", ".ppda lacks map {}"),60
(".ppda: atlasbestand '{}' is niet de verwachte naam in de exportmap", ".ppda: atlas file '{}' is not the expected name in the export folder"),61
(".ppda: atlas sha256 ongeldig", ".ppda: invalid atlas sha256"),62
(".ppda: {} atlasafmetingen {}x{} wijken af van raster {}x{}", ".ppda: {} atlas size {}x{} differs from grid {}x{}"),63
(".ppda zonder map", ".ppda without folder"),64
("ongeldige .ppda-naam", "invalid .ppda name"),65
(".ppda is geen UTF-8", ".ppda is not UTF-8"),66
("checksum mismatch voor '{}'", "checksum mismatch for '{}'"),67
("atlas PNG header: {e}", "atlas PNG header: {e}"),68
("{} atlas: afmetingen of kleurtype wijken af van .ppda", "{} atlas: size or color type differs from .ppda"),69
("geen .ppda in deze exportmap", "no .ppda in this export folder"),70
("meer dan één .ppda in deze exportmap", "more than one .ppda in this export folder"),71
("atlasset te groot om te laden ({decoded} bytes gedecodeerd)", "atlas set too large to load ({decoded} bytes decoded)"),72
("clip '{clip_token}' niet in .ppda", "clip '{clip_token}' not in .ppda"),73
("frame {frame} niet in clip '{clip_token}'", "frame {frame} not in clip '{clip_token}'"),74
("hoek p{pitch} y{yaw} niet in .ppda", "angle p{pitch} y{yaw} not in .ppda"),75
("atlascel {} f{} p{} y{} {} wijkt af van losse PNG", "atlas cell {} f{} p{} y{} {} differs from the loose PNG"),76
("{cells} atlascellen ≠ {} losse PNG's", "{cells} atlas cells ≠ {} loose PNGs"),77
("Vaste pivot Y {y:.3} m", "Fixed pivot Y {y:.3} m"),78
("Nekpivot {joint_name}: Y {y:.3} m (rustpose)", "Neck pivot {joint_name}: Y {y:.3} m (rest pose)"),79
("{reason}; centrum Y {y:.3} m als fallback — stel de pivot handmatig in", "{reason}; center Y {y:.3} m as fallback — set the pivot manually"),80
("Statisch lichaam zonder skeleton: geen nekbot", "Static body without skeleton: no neck bone"),81
("Geen nekbot gevonden", "No neck bone found"),82
("Meerdere losstaande/ongeldige nekketens", "Several separate/invalid neck chains"),83
("Ongeldige rustpose van nekbot", "Invalid rest pose of the neck bone"),84
("Nekhoogte buiten -10…10 m", "Neck height outside -10…10 m"),85
("linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]", "linear: distance = depth_near + (sample/65535)*(depth_far-depth_near); 16-bit gray+alpha; clamped to [near,far]"),86
("RGB-gepakt (nearest, lossless)", "RGB packed (nearest, lossless)"),87
("depth_encoding onbekend", "unknown depth_encoding"),88
("depth_bits moet 8 of 16 zijn", "depth_bits must be 8 or 16"),89
("{fmt} gray+alpha; t=gray/{}", "{fmt} gray+alpha; t=gray/{}"),90
("{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}", "{fmt} RGBA bands; v=round(t*{}), R=min(v,M), G=clamp(v-M,0,M), B=clamp(v-2M,0,M), M={}; t=(R+G+B)/{}"),91
("{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only", "{fmt} RGBA packed base {}; v=round(t*{}), R=v/B^2, G=(v/B)%B, B=v%B; nearest sampling, no mipmaps, lossless only"),92
("; distance=depth_near+t*(depth_far-depth_near), clamped", "; distance=depth_near+t*(depth_far-depth_near), clamped"),93
("depth rgb_bands: ongeldige bandvolgorde", "depth rgb_bands: invalid band order"),94
("geen lagen om te composeren", "no layers to compose"),95
("laagmaps hebben niet dezelfde afmetingen", "layer maps do not have the same size"),96
("afmetingen overlopen", "size overflow"),97
("buffergrootte past niet bij breedte×hoogte", "buffer size does not match width×height"),98
("depth-weergave past niet bij de cel", "depth display does not match the cell"),99
("{kind} verwacht RGBA8, kreeg depth16", "{kind} expects RGBA8, got depth16"),100
("depth verwacht gray-alpha16, kreeg RGBA8", "depth expects gray-alpha16, got RGBA8"),101
("project.id verschilt tussen exportsets", "project.id differs between export sets"),102
("geslacht verschilt tussen exportsets", "sex differs between export sets"),103
("body_model fingerprint verschilt", "body_model fingerprint differs"),104
("target-resolutie verschilt", "target resolution differs"),105
("fps verschilt", "fps differs"),106
("framing verschilt", "framing differs"),107
("near/far verschilt", "near/far differs"),108
("clip-inventaris verschilt", "clip inventory differs"),109
("lege afmetingen", "empty size"),110
("laag {index} ('{}') heeft andere afmetingen", "layer {index} ('{}') has a different size"),111
("laag {index} ('{}') heeft inconsistente buffers", "layer {index} ('{}') has inconsistent buffers"),112
("maximaal 255 lagen voor winner-index", "at most 255 layers for the winner index"),113
("composed buffers passen niet bij afmetingen", "composed buffers do not match the size"),114
("Map voor het nieuwe project", "Folder for the new project"),115
("Project laden", "Load project"),116
("Rigide equipment toevoegen", "Add rigid equipment"),117
("Rigide model", "Rigid model"),118
("Wavefront OBJ", "Wavefront OBJ"),119
("Equipment-preset importeren", "Import equipment preset"),120
("Paperdoll equipment", "Paperdoll equipment"),121
("bronbestand is geen regulier bestand", "source file is not a regular file"),122
("bronbestand groter dan {max_bytes} bytes", "source file larger than {max_bytes} bytes"),123
("Render alles{}: {} gerenderd, {} overgeslagen, {} mislukt", "Render all{}: {} rendered, {} skipped, {} failed"),124
(" (geannuleerd)", " (cancelled)"),125
(", {} niet gestart", ", {} not started"),126
("kies eerst een map waarin het project komt", "choose the folder for the project first"),127
("Nieuw project", "New project"),128
("nog niet gekozen", "not chosen yet"),129
("Map kiezen…", "Choose folder…"),130
("Wordt aangemaakt: {}", "Will be created: {}"),131
("{error} (ontbrekende bijbehorende bestanden: {})", "{error} (missing associated files: {})"),132
("3D: {error}", "3D: {error}"),133
("{relative}: {error}", "{relative}: {error}"),134
("{relative} staat in het project.", "{relative} is in the project."),135
("Onderdeel inclusief materiaal en textures ingesloten. Kies het doelbot.", "Part embedded including material and textures. Choose the target bone."),136
("geen project geopend", "no project open"),137
("dit lichaam heeft geen tekenbaar glTF/GLB-, FBX- of OBJ-model", "this body has no drawable glTF/GLB, FBX or OBJ model"),138
("geen projectmap", "no project folder"),139
("configureer eerst het equipment", "configure the equipment first"),140
("dit lichaam heeft geen modelpad", "this body has no model path"),141
("body-fingerprint: {e}", "body-fingerprint: {e}"),142
("equipment-fingerprint: {e}", "equipment-fingerprint: {e}"),143
("{name} · body", "{name} · body"),144
("geen lichaam ingesteld", "no body set"),145
("{} · equipment", "{} · equipment"),146
("equipment is niet geconfigureerd", "equipment is not configured"),147
("{name} · {}", "{name} · {}"),148
("geen lichaam ingesteld voor dit geslacht", "no body set for this sex"),149
("geannuleerd (hervatbaar)", "cancelled (resumable)"),150
("Render alles: item {}/{} · {}", "Render all: item {}/{} · {}"),151
("✔ {label}", "✔ {label}"),152
("overgeslagen: {label} — {reason}", "skipped: {label} — {reason}"),153
("mislukt: {label} — {reason}", "failed: {label} — {reason}"),154
("niet gestart: {label}", "not started: {label}"),155
("geen renderbare job voor deze weergave", "no renderable job for this view"),156
("Onderbroken renders:", "Interrupted renders:"),157
("Zoek onderbroken runs", "Look for interrupted runs"),158
("Alleen runs met exact dezelfde bronnen en instellingen kunnen verder.", "Only runs with exactly the same sources and settings can continue."),159
("geen gevonden voor deze set", "none found for this set"),160
("{} · {} · {} van {} PNG's vastgelegd", "{} · {} · {} of {} PNGs recorded"),161
("Glow-context ontbreekt", "Glow context missing"),162
("Batch opgeslagen: {path}", "Batch saved: {path}"),163
("De renderworker stopte zonder eindresultaat.", "The render worker stopped without a final result."),164
("Annuleer render", "Cancel render"),165
("Vaste renderhoogte (m)", "Fixed render height (m)"),166
("Centrum Y (m)", "Center Y (m)"),167
("Pivot Y (m)", "Pivot Y (m)"),168
("Auto nek", "Auto neck"),169
("Wis handmatige pivot; gebruik rustpose-nekhoogte", "Clear manual pivot; use the rest-pose neck height"),170
("Omhullende animaties", "Fit animations"),171
("Eenmalig: hoogte en centrum zo dat alle clips, frames en 60 hoeken binnen het kader vallen (+5% marge). Daarna weer vast; geen autozoom.", "One-off: height and center so that all clips, frames and 60 angles fit inside the frame (+5% margin). Fixed again afterwards; no autozoom."),172
("Vast renderkader tonen", "Show fixed render frame"),173
("Dieptebereik (m)", "Depth range (m)"),174
("T-pose spanwijdte + één armlengte marge aan elke kant, rond de pivot", "T-pose span + one arm length margin on each side, around the pivot"),175
("near {near:.3} m · far {far:.3} m (vast voor alle frames, hoeken en equipment)", "near {near:.3} m · far {far:.3} m (fixed for all frames, angles and equipment)"),176
("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de nekpivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the neck pivot."),177
("{} niveaus", "{} levels"),178
("RGB-gepakt vereist nearest sampling, geen mipmaps en lossless opslag (geen texturecompressie).", "RGB packed requires nearest sampling, no mipmaps and lossless storage (no texture compression)."),179
("Er zijn wijzigingen die nog niet zijn opgeslagen.", "There are unsaved changes."),180
("Niet opslaan", "Don't save"),181
("Paperdoll Renderer", "Paperdoll Renderer"),182
("Open een bestaand character.ppd of kies een map voor een nieuw project.", "Open an existing character.ppd or choose a folder for a new project."),183
("Recent geopend", "Recently opened"),184
("{name} (niet gevonden)", "{name} (not found)"),185
("Kies man", "Choose male"),186
("Open man", "Open male"),187
("Kies vrouw", "Choose female"),188
("Open vrouw", "Open female"),189
("Render alles", "Render all"),190
("Rendert alle lichamen en daarna al het equipment per geldig geslacht, elk naar renders/<geslacht>-<settype>-atlas/.", "Renders all bodies and then all equipment per valid sex, each to renders/<geslacht>-<settype>-atlas/."),191
("Opent de gerenderde 2D-compositor over de resultaatmappen in renders/.", "Opens the rendered 2D compositor over the result folders in renders/."),192
("geen model", "no model"),193
("Importoverzicht {title}", "Import overview {title}"),194
("{}: {} vertices, {} driehoeken", "{}: {} vertices, {} triangles"),195
("materiaal {}", "material {}"),196
("texture {}: {}×{}", "texture {}: {}×{}"),197
("{} joints", "{} joints"),198
("Render alle hoeken", "Render all angles"),199
("Live 3D", "Live 3D"),200
("{reason}. Render alle hoeken maakt één bindpose-sample (clip000-bind) × 60 hoeken.", "{reason}. Render all angles makes one bind-pose sample (clip000-bind) × 60 angles."),201
("Open een glTF/GLB-, FBX- of OBJ-lichaam voor de live 3D-weergave.", "Open a glTF/GLB, FBX or OBJ body for the live 3D view."),202
("Laatste voltooide pass: {}", "Last completed pass: {}"),203
("pass texture installed", "pass texture installed"),204
("Bodies → {} → Equipment", "Bodies → {} → Equipment"),205
("Terug naar body", "Back to body"),206
("{} {model}", "{} {model}"),207
("Preset importeren", "Import preset"),208
("Terug naar equipment", "Back to equipment"),209
("Naam (kleine letters/cijfers/-/_)", "Name (lowercase letters/digits/-/_)"),210
("Ongeldige naam; 'body' is gereserveerd.", "Invalid name; 'body' is reserved."),211
("Onderdeel toevoegen (OBJ/FBX)", "Add part (OBJ/FBX)"),212
("Geldig voor:", "Valid for:"),213
("Kies minstens één geslacht.", "Choose at least one sex."),214
("Herkomst / licentie (max. 4096 UTF-8 bytes)", "Origin / license (max. 4096 UTF-8 bytes)"),215
("Herkomst/licentie is te lang.", "Origin/license is too long."),216
("Positie relatief aan bot (XYZ)", "Position relative to bone (XYZ)"),217
("Rotatie in graden XYZ (Rz·Ry·Rx)", "Rotation in degrees XYZ (Rz·Ry·Rx)"),218
("Glanssterkte zonder texture", "Gloss strength without texture"),219
("Kies shinymap (PNG)", "Choose shiny map (PNG)"),220
("Wis shinymap", "Clear shiny map"),221
("Bot: {}", "Bone: {}"),222
("nog niet gekoppeld", "not linked yet"),223
("Skeleton / botten", "Skeleton / bones"),224
("Geen botten", "No bones"),225
("{reason}. Botkoppeling is niet beschikbaar; kies een geskind glTF/GLB- of FBX-lichaam.", "{reason}. Bone linking is not available; choose a skinned glTF/GLB or FBX body."),226
("Rigbinding ontbreekt of wijkt af; selecteer een bot.", "Rig binding missing or different; select a bone."),227
("Open een geskind glTF/GLB- of FBX-lichaam voor deze rigbinding.", "Open a skinned glTF/GLB or FBX body for this rig binding."),228
("Verwijder onderdeel", "Remove part"),229
("Verwijder equipmentdefinitie", "Remove equipment definition"),230
("Render equipment · alle hoeken", "Render equipment · all angles"),231
("Niet beschikbaar: statisch lichaam zonder skeleton (geen bot om aan te hangen)", "Not available: static body without skeleton (no bone to attach to)"),232
("{reason}. Equipment koppelen en renderen is voor dit lichaam niet beschikbaar.", "{reason}. Linking and rendering equipment is not available for this body."),233
("geen clip in de voltooide body-export", "no clip in the completed body export"),234
("geen selectie", "no selection"),235
("Vernieuw exportsets", "Refresh export sets"),236
("{} voltooide exportset(s) (.ppda + atlassen)", "{} completed export set(s) (.ppda + atlases)"),237
("{name}: {why}", "{name}: {why}"),238
("{} run(s) zonder geldige .ppda overgeslagen", "{} run(s) without a valid .ppda skipped"),239
("Equipment (zelfde project/rig/contract)", "Equipment (same project/rig/contract)"),240
("Geen compatibele equipment-export.", "No compatible equipment export."),241
("Geen body-export met clips voor dit geslacht.", "No body export with clips for this sex."),242
("{token} ({frames}f)", "{token} ({frames}f)"),243
("{name} ({token}, {frames}f)", "{name} ({token}, {frames}f)"),244
("frame {}/{} t={:.2}s", "frame {}/{} t={:.2}s"),245
("Pas toe", "Apply"),246
("export-fps {}", "export fps {}"),247
("Resultaat = belichte compositie; andere standen tonen de map zoals opgeslagen (onbelicht, niet gedecodeerd).", "Result = lit composite; the other modes show the map as stored (unlit, not decoded)."),248
("Hoek (export)", "Angle (export)"),249
("Yaw −", "Yaw −"),250
("Yaw +", "Yaw +"),251
("yaw {}°", "yaw {}°"),252
("Pitch −", "Pitch −"),253
("Pitch +", "Pitch +"),254
("pitch {}°", "pitch {}°"),255
("Licht (vasthouden; °/s — alleen Resultaat)", "Light (hold; °/s — Result only)"),256
("Azimut −", "Azimuth −"),257
("Azimut +", "Azimuth +"),258
("Elevatie −", "Elevation −"),259
("Elevatie +", "Elevation +"),260
("Azimut wrapt 0…360°; elevatie geklemd −80…+80. Alleen Resultaat gebruikt licht.", "Azimuth wraps 0…360°; elevation clamped −80…+80. Only Result uses light."),261
("Geen samengesteld frame. Render eerst body (en optioneel equipment) via Render alle hoeken.", "No composed frame. Render the body (and optionally equipment) first with Render all angles."),262
("Ander project", "Other project"),263
("Koppel {label}", "Link {label}"),264
("ongeldig geslacht", "invalid sex"),265
("dit bot bestaat niet", "this bone does not exist"),266
("dit onderdeel bestaat niet", "this part does not exist"),267
("equipmentnaam: kleine letters/cijfers/-/_, geen spaties, en niet 'body'", "equipment name: lowercase letters/digits/-/_, no spaces, and not 'body'"),268
("herkomst/licentie is te lang of bevat NUL", "origin/license is too long or contains NUL"),269
("kies minstens één geslacht", "choose at least one sex"),270
("te veel onderdelen of bestanden", "too many parts or files"),271
("ongeldige, dubbele of te grote asset", "invalid, duplicate or too large asset"),272
("te veel assetdata", "too much asset data"),273
("ongeldige rigbinding", "invalid rig binding"),274
("ongeldige bronassetverwijzing", "invalid source asset reference"),275
("attachment verwijst naar onbekend bot", "attachment refers to an unknown bone"),276
("ongeldige attachment/transformatie", "invalid attachment/transform"),277
("ongeldige of te grote equipmentmesh", "invalid or too large equipment mesh"),278
("textures samen groter dan 64 MiB", "textures together larger than 64 MiB"),279
("textureverwijzing ontbreekt", "texture reference missing"),280
("presetmetadata is te groot", "preset metadata is too large"),281
("preset te groot", "preset too large"),282
("preset is te groot", "preset is too large"),283
("ongeldig equipmentbestand of bestand te groot", "invalid equipment file or file too large"),284
("onbekende equipmentversie", "unknown equipment version"),285
("beschadigde equipmentdata", "damaged equipment data"),286
("te veel assets", "too many assets"),287
("afgebroken asset", "truncated asset"),288
("ongeldige assetlengte", "invalid asset length"),289
("onverwachte extra equipmentdata", "unexpected extra equipment data"),290
("kies een regulier presetbestand", "choose a regular preset file"),291
("snapshot te groot", "snapshot too large"),292
("snapshot is geen regulier bestand", "snapshot is not a regular file"),293
("snapshotnaam bestaat met andere inhoud", "snapshot name exists with different content"),294
("snapshotmap ontbreekt", "snapshot folder missing"),295
("texture is groter dan 2048×2048", "texture is larger than 2048×2048"),296
("ongeldige texturegrootte", "invalid texture size"),297
("texture is te groot", "texture is too large"),298
("onbekende texturecodering", "unknown texture encoding"),299
("texture ontbreekt", "texture missing"),300
("rig wijkt af: kies de botten opnieuw voor dit lichaam", "rig differs: choose the bones again for this body"),301
("kies een bot voor dit onderdeel en geslacht", "choose a bone for this part and sex"),302
("het gekoppelde bot ontbreekt", "the linked bone is missing"),303
("kies een rigide OBJ-model met MTL/PNG-bijbestanden", "choose a rigid OBJ model with MTL/PNG side files"),304
("modelmap ontbreekt", "model folder missing"),305
("ongeldige modelnaam", "invalid model name"),306
("te veel onderdelen", "too many parts"),307
("disp ", "disp "),308
("ongeldige textureverwijzing", "invalid texture reference"),309
("ongeldig texturepad", "invalid texture path"),310
("te veel bijbestanden", "too many side files"),311
("assets samen te groot", "assets together too large"),312
("equipmentmesh is te groot", "equipment mesh is too large"),313
("ontbrekende diffuse texture", "missing diffuse texture"),314
("het OBJ-bestand heeft geen meshes", "the OBJ file has no meshes"),315
("kies een rigide OBJ- (met MTL/PNG) of FBX-model", "choose a rigid OBJ (with MTL/PNG) or FBX model"),316
("kies een rigide FBX-model", "choose a rigid FBX model"),317
("FBX laden mislukt: {}", "loading FBX failed: {}"),318
("FBX heeft geometrie genegeerd; bestand is niet tekenbaar", "FBX ignored geometry; the file is not drawable"),319
("assets samen te groot of te veel bijbestanden", "assets together too large or too many side files"),320
("FBX-mesh '{}' is geskinned; alleen rigide equipment wordt ondersteund", "FBX mesh '{}' is skinned; only rigid equipment is supported"),321
("FBX-mesh '{}' heeft morph targets; die worden niet ondersteund", "FBX mesh '{}' has morph targets; these are not supported"),322
("FBX-mesh bevat niet-eindige posities of normalen", "FBX mesh contains non-finite positions or normals"),323
("FBX-materiaal heeft een niet-eindige kleur", "FBX material has a non-finite color"),324
("FBX-texture heeft geen relatief pad en is niet ingebed", "FBX texture has no relative path and is not embedded"),325
("FBX-texturepad geweigerd: {e}", "FBX texture path refused: {e}"),326
("FBX-texture '{relative}': {e}", "FBX-texture '{relative}': {e}"),327
("FBX-texture is te groot", "FBX texture is too large"),328
("FBX-texture is geen bruikbare PNG (alleen PNG wordt ingesloten): {e}", "FBX texture is not a usable PNG (only PNG is embedded): {e}"),329
("het FBX-bestand heeft geen tekenbare meshes", "the FBX file has no drawable meshes"),330
("texturemap ontbreekt", "texture folder missing"),331
("ongeldige bestandsnaam", "invalid file name"),332
("onderdeel ontbreekt", "part missing"),333
("finite asset list", "finite asset list"),334
("ongeldige assetverwijzing", "invalid asset reference"),335
("asset verlaat de modelmap", "asset leaves the model folder"),336
("asset is geen regulier bestand", "asset is not a regular file"),337
("asset is te groot", "asset is too large"),338
("ongeldige OBJ-index", "invalid OBJ index"),339
("view (+Y up), PNG rows top-down", "view (+Y up), PNG rows top-down"),340
("camera on +Z", "camera on +Z"),341
("synthetic fixture", "synthetic fixture"),342
("run is geannuleerd of mislukt; complete.json ontbreekt of is ongeldig", "run is cancelled or failed; complete.json is missing or invalid"),343
("complete.json ontbreekt; de exportset is niet volledig", "complete.json is missing; the export set is not complete"),344
("render.json: {e}", "render.json: {e}"),345
("complete.json: {e}", "complete.json: {e}"),346
("complete.json png_sha256 telt niet gelijk aan png_files", "complete.json png_sha256 count does not equal png_files"),347
("png_files in complete.json wijkt af van render.json", "png_files in complete.json differs from render.json"),348
("PNG '{relative}' groter dan {MAX_PNG_BYTES} bytes", "PNG '{relative}' larger than {MAX_PNG_BYTES} bytes"),349
("checksum mismatch voor '{relative}'", "checksum mismatch for '{relative}'"),350
("ongeldig map-pad", "invalid map path"),351
("map '{key}' staat niet in complete.json", "map '{key}' is not in complete.json"),352
("onbekende map '{relative}'", "unknown map '{relative}'"),353
("geen map-token in '{relative}'", "no map token in '{relative}'"),354
("onbekend map-token '{token}' in '{relative}'", "unknown map token '{token}' in '{relative}'"),355
("verwachtte {} maar pad is {}", "expected {} but path is {}"),356
("depth_bits past niet bij render.json-schema", "depth_bits does not match the render.json schema"),357
("render.json schema 4: oude export met globaal dieptebereik 0,05–40 m; opnieuw renderen", "render.json schema 4: old export with global depth range 0.05–40 m; render again"),358
("render.json format_version {} wordt niet ondersteund (verwacht {OUTPUT_META_VERSION})", "render.json format_version {} is not supported (expected {OUTPUT_META_VERSION})"),359
("onbekende render.json status '{}'", "unknown render.json status '{}'"),360
("ongeldige setnaam", "invalid set name"),361
("target buiten 1–256", "target outside 1–256"),362
("ongeldige fps", "invalid fps"),363
("highres_scale wijkt af van 8", "highres_scale differs from 8"),364
("near/far wijkt af van het vaste dieptebereik van deze framing", "near/far differs from the fixed depth range of this framing"),365
("camera_distance wijkt af van contract", "camera_distance differs from the contract"),366
("pitches wijken af van −60,−30,0,+30,+60", "pitches differ from −60,−30,0,+30,+60"),367
("yaws wijken af van 0…330 stap 30", "yaws differ from 0…330 step 30"),368
("pass_order wijkt af van contract", "pass_order differs from the contract"),369
("saved_maps wijkt af van diffuse/depth/normal/specular", "saved_maps differs from diffuse/depth/normal/specular"),370
("clip-inventaris ongeldig", "invalid clip inventory"),371
("ongeldige clip-token '{}'", "invalid clip token '{}'"),372
("clipnaam te lang", "clip name too long"),373
("clip zonder frames", "clip without frames"),374
("ongeldige clipduur", "invalid clip duration"),375
("png_files buiten limiet", "png_files outside the limit"),376
("body_model.sha256 is geen SHA-256 hex", "body_model.sha256 is not SHA-256 hex"),377
("project.id ongeldig", "invalid project.id"),378
("project.name te lang", "project.name too long"),379
("equipment_preset past niet bij set of mist geldige sha256", "equipment_preset does not match the set or lacks a valid sha256"),380
("onbekende pivot_source.kind '{other}'", "unknown pivot_source.kind '{other}'"),381
("complete.json format_version {} wordt niet ondersteund", "complete.json format_version {} is not supported"),382
("complete.json status is niet 'complete'", "complete.json status is not 'complete'"),383
("te veel png_sha256-items", "too many png_sha256 items"),384
("identity.body_model_sha256 ongeldig", "invalid identity.body_model_sha256"),385
("identity.equipment_preset_sha256 ongeldig", "invalid identity.equipment_preset_sha256"),386
("png_sha256-sleutel '{path}' is geen .png", "png_sha256 key '{path}' is not a .png"),387
("png_sha256 voor '{path}' is geen SHA-256 hex", "png_sha256 for '{path}' is not SHA-256 hex"),388
("project.id mismatch", "project.id mismatch"),389
("project.name mismatch", "project.name mismatch"),390
("sex mismatch", "sex mismatch"),391
("set mismatch", "set mismatch"),392
("body_model sha256 mismatch", "body_model sha256 mismatch"),393
("target mismatch", "target mismatch"),394
("fps mismatch", "fps mismatch"),395
("framing mismatch", "framing mismatch"),396
("near/far mismatch", "near/far mismatch"),397
("saved_maps mismatch", "saved_maps mismatch"),398
("atlas.ppda naam/sha256 ongeldig", "invalid atlas.ppda name/sha256"),399
("atlas.maps moet vier maps bevatten", "atlas.maps must contain four maps"),400
("atlas mist {}", "atlas lacks {}"),401
("atlas-bestandsnaam/sha256 ongeldig", "invalid atlas file name/sha256"),402
("clips length mismatch", "clips length mismatch"),403
("clip inventory mismatch", "clip inventory mismatch"),404
("pivot_source mismatch", "pivot_source mismatch"),405
("equipment preset sha256 mismatch", "equipment preset sha256 mismatch"),406
("PNG-afmetingen buiten limiet", "PNG size outside the limit"),407
("PNG header: {e}", "PNG header: {e}"),408
("PNG-afmetingen {}x{} wijken af van target {expect_w}x{expect_h}", "PNG size {}x{} differs from target {expect_w}x{expect_h}"),409
("PNG groter dan limiet", "PNG larger than the limit"),410
("RGBA8-map heeft onverwacht kleurtype/bitdiepte", "RGBA8 map has an unexpected color type/bit depth"),411
("PNG buffergrootte onbekend of te groot", "PNG buffer size unknown or too large"),412
("PNG data: {e}", "PNG data: {e}"),413
("PNG buffergrootte klopt niet", "PNG buffer size is wrong"),414
("depth-map moet {} {}-bit zijn", "depth map must be {} {}-bit"),415
("depth buffergrootte klopt niet", "depth buffer size is wrong"),416
("transparante depth heeft niet-nul gray", "transparent depth has non-zero gray"),417
("transparante normal heeft niet-nul RGB", "transparent normal has non-zero RGB"),418
("normal lengte {len} buiten tolerantie", "normal length {len} out of tolerance"),419
("specular RGB-kanalen zijn niet gelijk", "specular RGB channels are not equal"),420
("runpad is geen map", "run path is not a folder"),421
("complete.json ontbreekt en er is geen .ppda; de exportset is niet volledig", "complete.json is missing and there is no .ppda; the export set is not complete"),422
(".ppda bevat geen exportgegevens en complete.json ontbreekt", ".ppda has no export data and complete.json is missing"),423
("{name} ontbreekt", "{name} is missing"),424
("{} groter dan {max} bytes", "{} larger than {max} bytes"),425
("symlink geweigerd: {}", "symlink refused: {}"),426
("geen regulier bestand: {}", "not a regular file: {}"),427
("pad verlaat runmap: {}", "path leaves the run folder: {}"),428
("render.json mist project", "render.json lacks project"),429
("body sha", "body sha"),430
("FBX bevat geen skinned mesh met driehoeken", "FBX contains no skinned mesh with triangles"),431
("het FBX-pad is geen UTF-8", "the FBX path is not UTF-8"),432
("FBX openen mislukt: {err}", "opening FBX failed: {err}"),433
("FBX-bestand ontbreekt: {}", "FBX file missing: {}"),434
("FBX-bestand is groter dan {MAX_BYTES} bytes ({})", "FBX file is larger than {MAX_BYTES} bytes ({})"),435
("FBX laden mislukt ({}): {}", "loading FBX failed ({}): {}"),436
("FBX genormaliseerd met {FBX_BACKEND}; assen right-handed Y-up, eenheden meters.", "FBX normalized with {FBX_BACKEND}; axes right-handed Y-up, units meters."),437
("Dezelfde genormaliseerde scene voedt de live viewport en de productierender.", "The same normalized scene feeds the live viewport and the production render."),438
("animatiegegevens waren gemarkeerd als genegeerd door de loader", "animation data was marked as ignored by the loader"),439
("skinned mesh heeft geen skin deformer", "skinned mesh has no skin deformer"),440
("niet-ondersteunde FBX-skinningmethode {:?}; alleen linear/rigid", "unsupported FBX skinning method {:?}; only linear/rigid"),441
("FBX-skin heeft geen clusters", "FBX skin has no clusters"),442
("FBX-skeleton heeft {} joints; limiet is {MAX_JOINTS}", "FBX skeleton has {} joints; the limit is {MAX_JOINTS}"),443
("{} blend deformers genegeerd (morph targets niet ondersteund)", "{} blend deformers ignored (morph targets not supported)"),444
("FBX-mesh te groot ({} vertices, {} indices)", "FBX mesh too large ({} vertices, {} indices)"),445
("geen animatiestacks met transformkanalen gevonden", "no animation stacks with transform channels found"),446
("right-handed Y-up", "right-handed Y-up"),447
("node '{}' is een geometry-transform helper die niet is weggenomen; dit dialect wordt niet stil geaccepteerd", "node '{}' is a geometry-transform helper that was not removed; this dialect is not silently accepted"),448
("node '{}' heeft niet-eindige lokale transform", "node '{}' has a non-finite local transform"),449
("ufbx typed_id {} komt niet overeen met node-index {index}", "ufbx typed_id {} does not match node index {index}"),450
("skin cluster zonder bone_node", "skin cluster without bone_node"),451
("skin cluster bone heeft geen naam", "skin cluster bone has no name"),452
("inverse-bind van '{name}' is niet eindig", "inverse bind of '{name}' is not finite"),453
("materiaal '{}' heeft niet-eindige basiskleur", "material '{}' has a non-finite base color"),454
("ingebedde texture '{name}' kon niet gelezen worden: {err}", "embedded texture '{name}' could not be read: {err}"),455
("texture '{name}' is groter dan 2048×2048", "texture '{name}' is larger than 2048×2048"),456
("texture '{filename}' lezen mislukt: {err}", "reading texture '{filename}' failed: {err}"),457
("texture '{filename}' is geen geldige 8-bit PNG: {err}", "texture '{filename}' is not a valid 8-bit PNG: {err}"),458
("texture '{filename}' is groter dan 2048×2048", "texture '{filename}' is larger than 2048×2048"),459
("FBX-texture ontbreekt: {filename}", "FBX texture missing: {filename}"),460
("texture-referentie '{filename}' zonder bestand genegeerd", "texture reference '{filename}' without a file ignored"),461
("onveilig texturepad geweigerd: {relative}", "unsafe texture path refused: {relative}"),462
("textureafmeting ongeldig of >2048", "texture size invalid or >2048"),463
("texturebuffer te groot", "texture buffer too large"),464
("niet-ondersteund PNG-kleurtype {other:?}", "unsupported PNG color type {other:?}"),465
("materiaalpart zonder seed", "material part without seed"),466
("FBX-mesh heeft geen tekenbare driehoeken na triangulatie", "FBX mesh has no drawable triangles after triangulation"),467
("skin vertex-index {vertex} buiten bereik", "skin vertex index {vertex} out of range"),468
("skin weight verwijst naar onbekende cluster {}", "skin weight refers to unknown cluster {}"),469
("skin weight is niet eindig of negatief", "skin weight is not finite or negative"),470
("skin weights sommeren tot nul", "skin weights sum to zero"),471
("animatiestack '{}' bakken mislukt: {}", "baking animation stack '{}' failed: {}"),472
("gebakken node typed_id {node} buiten bereik ({} nodes)", "baked node typed_id {node} out of range ({} nodes)"),473
("gebakken vec3-kanaal heeft niet-eindige waarden", "baked vec3 channel has non-finite values"),474
("gebakken rotatiekanaal heeft niet-eindige waarden", "baked rotation channel has non-finite values"),475
("het projectpad is leeg of bevat een leeg teken", "the project path is empty or contains a NUL character"),476
("open een .ppd-bestand of een projectmap met character.ppd", "open a .ppd file or a project folder with character.ppd"),477
("geen projectbestand op {}", "no project file at {}"),478
("Content Type", "Content Type"),479
("HOME of XDG_DATA_HOME ontbreekt", "HOME or XDG_DATA_HOME missing"),480
("{command}: {e}", "{command}: {e}"),481
("{command} is mislukt", "{command} failed"),482
("het uitvoerbare pad moet UTF-8 zijn", "the executable path must be UTF-8"),483
("kies een absoluut pad zonder stuurtekens", "choose an absolute path without control characters"),484
("[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nStartupWMClass=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n", "[Desktop Entry]\nType=Application\nName=Paperdoll Renderer\nExec=\"{escaped}\" %f\nIcon=paperdoll-renderer\nStartupWMClass=paperdoll-renderer\nTerminal=false\nCategories=Graphics;3DGraphics;\nMimeType={MIME_TYPE};\n"),485
("OBJ + MTL + textures", "OBJ + MTL + textures"),486
("FBX drawable scene", "FBX drawable scene"),487
("niet gebouwd", "not built"),488
("Deze build leest glTF/GLB, FBX en OBJ+MTL, opent een .ppd-project en toont een live geskinde 3D-preview van glTF/GLB- en FBX-lichamen (FBX via een eenmalig genormaliseerde tekenbare/animeerbare scene) met al hun clips (zoals walk en attack), rigide bone-gebonden OBJ- of FBX-equipment en binaire .ppe-presets. Render maakt voor glTF/GLB- en FBX-lichamen alle clips × frames × 60 hoeken (5 pitches × 12 yaws) met vaste orthografische camera en diffuse/depth/normal/specular op doelresolutie, plus per maptype een atlas en een .ppda. De 2D-preview leest exports via .ppda en atlassen en composeert ze met depth-ordering, looping-playback, hoeknavigatie en continue lichtbediening.", "This build reads glTF/GLB, FBX and OBJ+MTL, opens a .ppd project and shows a live skinned 3D preview of glTF/GLB and FBX bodies (FBX via a once-normalized drawable/animatable scene) with all their clips (such as walk and attack), rigid bone-attached OBJ or FBX equipment and binary .ppe presets. Render produces, for glTF/GLB and FBX bodies, all clips × frames × 60 angles (5 pitches × 12 yaws) with a fixed orthographic camera and diffuse/depth/normal/specular at target resolution, plus one atlas per map type and a .ppda. The 2D preview reads exports through .ppda and atlases and composes them with depth ordering, looping playback, angle navigation and continuous light control."),489
("name: Paperdoll Renderer", "name: Paperdoll Renderer"),490
("phase: project-editor", "phase: project-editor"),491
("het venster kan niet openen; deze bibliotheken ontbreken: {}.", "the window cannot open; these libraries are missing: {}."),492
("Paperdoll Renderer\n\nGebruik:\n paperdoll-renderer startscherm\n paperdoll-renderer pad/naar/character.ppd open dat project\n paperdoll-renderer --install-desktop registreer Openen met op Linux\n paperdoll-renderer --status print de status en sluit\n paperdoll-renderer --help toon deze tekst\n paperdoll-renderer --preview-fixture open een voorbeeldproject op het mannelijke lichaam\n\n--status staat alleen. Een .ppd-pad of een projectmap opent character.ppd.", "Paperdoll Renderer\n\nUsage:\n paperdoll-renderer start screen\n paperdoll-renderer path/to/character.ppd open that project\n paperdoll-renderer --install-desktop register Open with on Linux\n paperdoll-renderer --status print the status and exit\n paperdoll-renderer --help show this text\n paperdoll-renderer --preview-fixture open a sample project on the male body\n\n--status stands alone. A .ppd path or a project folder opens character.ppd."),493
("geef één projectpad", "give one project path"),494
("--status staat alleen en neemt geen projectpad aan", "--status stands alone and takes no project path"),495
("--preview-fixture opent het voorbeeld en neemt geen projectpad aan", "--preview-fixture opens the sample and takes no project path"),496
("application icon png", "application icon png"),497
("png has no frame", "png has no frame"),498
("icon must decode to 8-bit rgba, got {:?} {:?}", "icon must decode to 8-bit rgba, got {:?} {:?}"),499
("icon frame size does not match rgba pixels", "icon frame size does not match rgba pixels"),500
("ufbx 0.11.5, meegecompileerd uit bron (geen losse libassimp)", "ufbx 0.11.5, compiled in from source (no separate libassimp)"),501
("het modelbestand ontbreekt: {path}", "the model file is missing: {path}"),502
("importeren van {path} is mislukt: {detail}", "importing {path} failed: {detail}"),503
("het bestand is groter dan {MAX_BYTES} bytes", "the file is larger than {MAX_BYTES} bytes"),504
("bestandstype '{other}' wordt niet gelezen. Gebruik .glb, .gltf, .fbx of .obj.", "file type '{other}' is not read. Use .glb, .gltf, .fbx or .obj."),505
("mesh '{}' heeft een niet-eindige positie", "mesh '{}' has a non-finite position"),506
("mesh '{}' heeft geen driehoeken", "mesh '{}' has no triangles"),507
("een skin-weight is niet eindig of is negatief", "a skin weight is not finite or is negative"),508
("het aantal inverse-bindmatrices wijkt af van het aantal joints", "the number of inverse bind matrices differs from the number of joints"),509
("een animatiesample is niet eindig", "an animation sample is not finite"),510
("clip {index} heeft geen naam", "clip {index} has no name"),511
("dit glTF-bestand heeft geen skeleton. Bot- en clipfuncties blijven uit tot er skins in het bestand staan.", "this glTF file has no skeleton. Bone and clip features stay off until the file contains skins."),512
("Lcl Translation", "Lcl Translation"),513
("Lcl Rotation", "Lcl Rotation"),514
("Lcl Scaling", "Lcl Scaling"),515
("het FBX-bestand bevat geen mesh", "the FBX file contains no mesh"),516
("FBX gelezen met {FBX_BACKEND}. Importoverzicht toont meshes/joints/clips; genormaliseerde drawable scene via fbx_scene. Live viewport en productie gebruiken nog glTF/GLB.", "FBX read with {FBX_BACKEND}. The import overview shows meshes/joints/clips; normalized drawable scene via fbx_scene. Live viewport and production still use glTF/GLB."),517
("de FBX-texture ontbreekt", "the FBX texture is missing"),518
("MTL ontbreekt of is onleesbaar: {err}", "MTL missing or unreadable: {err}"),519
("mesh '{}' heeft een ongeldige positie", "mesh '{}' has an invalid position"),520
("mesh '{}' is niet in driehoeken", "mesh '{}' is not triangulated"),521
("OBJ is een statische mesh voor equipment. Dit formaat heeft geen skeleton en levert geen skeletal animation clips.", "OBJ is a static mesh for equipment. This format has no skeleton and provides no skeletal animation clips."),522
("de texture uit de MTL ontbreekt", "the texture from the MTL is missing"),523
("texturepad '{value}' wijst buiten de projectmap", "texture path '{value}' points outside the project folder"),524
("glTF-verwijzing '{uri}' wordt niet gevolgd", "glTF reference '{uri}' is not followed"),525
("dit is geen GLB", "this is not a GLB"),526
("de GLB heeft geen JSON-chunk", "the GLB has no JSON chunk"),527
("het pad '{label}' wijst buiten de projectmap", "the path '{label}' points outside the project folder"),528
("kanaal zonder keyframes", "channel without keyframes"),529
("keyframetijden niet eindig of niet oplopend", "keyframe times not finite or not increasing"),530
("{} waarden voor {} keyframes (verwacht {per_key} per key)", "{} values for {} keyframes (expected {per_key} per key)"),531
("niet-eindige keyframewaarde", "non-finite keyframe value"),532
("FBX zonder skin: statische rustpose, geen skeleton of animaties", "FBX without skin: static rest pose, no skeleton or animations"),533
("OBJ heeft geen skeleton of animaties: statische pose", "OBJ has no skeleton or animations: static pose"),534
("{} is geen tekenbaar lichaam; gebruik .glb, .gltf, .fbx of .obj", "{} is not a drawable body; use .glb, .gltf, .fbx or .obj"),535
("statisch {format}-lichaam: {error}", "static {format} body: {error}"),536
("statisch lichaam: ongeldige driehoeksindices", "static body: invalid triangle indices"),537
("statisch lichaam: niet-eindige posities of normalen", "static body: non-finite positions or normals"),538
("statisch lichaam heeft te veel vertices", "static body has too many vertices"),539
("statisch {format}-lichaam heeft geen tekenbare driehoeken", "static {format} body has no drawable triangles"),540
("ongeldig glTF-bestand (parser weigerde het document)", "invalid glTF file (the parser refused the document)"),541
("het skeleton heeft {} joints; de preview stopt bij {MAX_JOINTS}", "the skeleton has {} joints; the preview stops at {MAX_JOINTS}"),542
("inverse-bindmatrices en joints verschillen", "inverse bind matrices and joints differ"),543
("glTF-nodehiërarchie bevat een cyclus", "glTF node hierarchy contains a cycle"),544
("singuliere joint-rustmatrix", "singular joint rest matrix"),545
("skinjoint buiten skin", "skin joint outside the skin"),546
("glTF-node heeft een singuliere transformatie", "glTF node has a singular transform"),547
("het glTF-bestand heeft geen tekenbare driehoeken", "the glTF file has no drawable triangles"),548
("glTF-mesh heeft niet-eindige posities na de node-transformatie", "glTF mesh has non-finite positions after the node transform"),549
("node {target}: geen uitvoerwaarden", "node {target}: no output values"),550
("node {target}: morph-target-gewichten (niet ondersteund)", "node {target}: morph target weights (not supported)"),551
("animatie '{}': ongeldig kanaal op node {target}: {reason}", "animation '{}': invalid channel on node {target}: {reason}"),552
("animatie {}", "animation {}"),553
("live/productie ondersteunt alleen driehoekprimitieven", "live/production supports only triangle primitives"),554
("glTF-primitive heeft niet-eindige posities", "glTF primitive has non-finite positions"),555
("glTF-primitive heeft ongeldige normalen", "glTF primitive has invalid normals"),556
("glTF-primitive heeft ongeldige joints/gewichten", "glTF primitive has invalid joints/weights"),557
("glTF-primitive heeft indices buiten het vertexbereik", "glTF primitive has indices outside the vertex range"),558
("ontbrekende bodytexture", "missing body texture"),559
("bodytexture moet 8-bit zijn", "body texture must be 8-bit"),560
("bodytexture groter dan 2048×2048", "body texture larger than 2048×2048"),561
("ongeldige glTF-node", "invalid glTF node"),562
("glTF-node heeft een niet-eindige transformatie", "glTF node has a non-finite transform"),563
("glTF-primitive heeft een onvolledige driehoek", "glTF primitive has an incomplete triangle"),564
("glTF zonder skin: statische pose, geen skeleton of animaties", "glTF without skin: static pose, no skeleton or animations"),565
("glTF zonder skin: statische pose; {animations} animatie(s) niet toegepast (geen skeleton)", "glTF without skin: static pose; {animations} animation(s) not applied (no skeleton)"),566
("Alleen diffuse", "Diffuse only"),567
("Alleen normal map", "Normal map only"),568
("Alleen depth map", "Depth map only"),569
("Alleen specular map", "Specular map only"),570
("oude run-map uit een eerdere versie; genegeerd (opnieuw renderen naar <sekse>-<settype>-atlas)", "old run folder from an earlier version; ignored (render again to <sekse>-<settype>-atlas)"),571
("mapnaam past niet bij inhoud ({})", "folder name does not match its content ({})"),572
("ontbrekende maps voor set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}", "missing maps for set '{set}' clip '{clip}' frame {frame} pitch {pitch} yaw {yaw}: {detail}"),573
("geen body-exportset voor dit geslacht", "no body export set for this sex"),574
("geen voltooide exportset voor dit equipment", "no completed export set for this equipment"),575
("geen voltooide body-export voor dit geslacht", "no completed body export for this sex"),576
("Geen animaties: statisch lichaam zonder skeleton (bindpose)", "No animations: static body without skeleton (bind pose)"),577
("Geen animaties: bindpose", "No animations: bind pose"),578
("pitch {pitch:.0}", "pitch {pitch:.0}"),579
("yaw {:.0}° / {} pitch {:.0}°", "yaw {:.0}° / {} pitch {:.0}°"),580
("Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws.", "Drag to rotate, scroll to zoom. Yaw 0 looks at the front (+Z, glTF standard). The buttons set the five pitches and the twelve yaws."),581
("Geen lichaam geladen. Open een project met een .glb/.gltf/.fbx/.obj-lichaam of start met --preview-fixture.", "No body loaded. Open a project with a .glb/.gltf/.fbx/.obj body or start with --preview-fixture."),582
("preview gpu lock", "preview gpu lock"),583
("Vast renderkader", "Fixed render frame"),584
("native depth attachment ontbreekt: {native_bits} bits", "native depth attachment missing: {native_bits} bits"),585
("onvolledig offscreen depth-target", "incomplete offscreen depth target"),586
("achterste vlak overschrijft voorste: {near:?}", "back face overwrites front: {near:?}"),587
("depth niet gewist voor volgende frame: {next:?}", "depth not cleared for the next frame: {next:?}"),588
("draw-order afhankelijke occlusie: {near:?} / {reverse:?}", "draw-order dependent occlusion: {near:?} / {reverse:?}"),589
("equipment wist body-depth: {hidden:?}", "equipment clears body depth: {hidden:?}"),590
("voorste equipmenttexture ontbreekt: {textured:?}", "front equipment texture missing: {textured:?}"),591
("alpha-cutout bedekt body: {cutout:?}", "alpha cutout covers body: {cutout:?}"),592
("shinymap beïnvloedt specular niet correct: {shiny:?}/{matte:?}", "shiny map does not affect specular correctly: {shiny:?}/{matte:?}"),593
("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}"),594
("target-res / diffuse", "target-res / diffuse"),595
("fps moet 1–240 zijn", "fps must be 1–240"),596
("ongeldige of te lange animatie", "invalid or too long animation"),597
("te veel frames", "too many frames"),598
("te veel beelden", "too many images"),599
("te veel passes", "too many passes"),600
("te veel bestanden", "too many files"),601
("batch groter dan 500000 uitvoerbestanden", "batch larger than 500000 output files"),602
("ongeldig uitvoerpad/token", "invalid output path/token"),603
("ongeldige skinjoint", "invalid skin joint"),604
("ongeldige GPU-scène/afmetingen", "invalid GPU scene/size"),605
("ongeldige of te grote GPU-geometrie", "invalid or too large GPU geometry"),606
("ongeldige GPU-texture", "invalid GPU texture"),607
("productie-FBO onvolledig", "production FBO incomplete"),608
("highres moet 1–2048 pixels zijn (8× target)", "highres must be 1–2048 pixels (8× target)"),609
("shader ontbreekt", "shader missing"),610
("ongeldige GPU-geometrie", "invalid GPU geometry"),611
("productie-GL fout 0x{error:x}", "production GL error 0x{error:x}"),612
("te veel pixels", "too many pixels"),613
("ongeldige GPU-mapafmetingen/data", "invalid GPU map size/data"),614
("ongeldige target-afmetingen", "invalid target size"),615
("ongeldige 8× resolve-afmetingen", "invalid 8× resolve size"),616
("ongeldige depth/coverage in GPU-resultaat", "invalid depth/coverage in GPU result"),617
("ongeldig uitvoerpad", "invalid output path"),618
("uitvoermap ontbreekt", "output folder missing"),619
("uitvoer verlaat projectmap", "output leaves the project folder"),620
("onveilig uitvoerpad", "unsafe output path"),621
("ongeldige PNG-afmetingen", "invalid PNG size"),622
("ongeldige 16-bit depthafmetingen", "invalid 16-bit depth size"),623
("ongeldige depthafmetingen", "invalid depth size"),624
("8× highres vereist targetafmetingen 1–256", "8× highres requires target size 1–256"),625
("statisch lichaam zonder skeleton: equipment kan niet aan een bot hangen", "static body without skeleton: equipment cannot be attached to a bone"),626
("equipment niet geldig voor dit geslacht", "equipment not valid for this sex"),627
("equipment heeft geen onderdelen", "equipment has no parts"),628
("batch groter dan 16 GiB ongecomprimeerde targetmaps", "batch larger than 16 GiB of uncompressed target maps"),629
("project.id ongeldig voor export-identiteit", "project.id invalid for the export identity"),630
("project.name te lang voor export-identiteit", "project.name too long for the export identity"),631
("body_model.sha256 ongeldig", "invalid body_model.sha256"),632
("equipment fingerprint past niet bij de job", "equipment fingerprint does not match the job"),633
("clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained", "clip -> frame (t=frame/fps) -> pitch -> yaw -> pass; ceil(duration*fps) snapped within 4*f32_epsilon*max(1,samples) of an integer to omit floating terminal duplicates; root motion retained"),634
("straight, opaque/cutout, coverage resolve", "straight, opaque/cutout, coverage resolve"),635
("unlit sRGB albedo × vertex color, linear-light detail resolve", "unlit sRGB albedo × vertex color, linear-light detail resolve"),636
("linear scalar; body defaults zero", "linear scalar; body defaults zero"),637
("Herstel: {note}", "Recovery: {note}"),638
("Oude onvoltooide render verwijderd: {name}", "Old unfinished render removed: {name}"),639
("Controle bestaande uitvoer: {done}/{all} PNG's", "Checking existing output: {done}/{all} PNGs"),640
("Let op: clip '{}': kanaal niet toegepast: {reason}", "Note: clip '{}': channel not applied: {reason}"),641
("{} {set} · {} f{frame}/{} · p{pitch} y{yaw}", "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}"),642
("{pose} · al aanwezig, checksums kloppen", "{pose} · already present, checksums match"),643
("{pose} · highres", "{pose} · highres"),644
("GPU-service gesloten", "GPU service closed"),645
("GPU-service gestopt", "GPU service stopped"),646
("Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)", "Warning: {pose} · {clamped} highres pixels outside depth range {:.2}–{:.2} m (clamped)"),647
("{pose} · detail", "{pose} · detail"),648
("{pose} · target-res / diffuse", "{pose} · target-res / diffuse"),649
("{pose} · {map}", "{pose} · {map}"),650
("{pose} · {pass} (bestaand bestand behouden)", "{pose} · {pass} (existing file kept)"),651
("{pose} · {pass}", "{pose} · {pass}"),652
("checksumtelling {} wijkt af van geplande {} PNGs", "checksum count {} differs from planned {} PNGs"),653
("rendered this attempt; kept files from a resumed run are not re-counted", "rendered this attempt; kept files from a resumed run are not re-counted"),654
("Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)", "Warning: depth clamped in {clamped_views} images ({clamped_pixels} highres pixels outside the fixed range)"),655
("voltooide render faalt controle: {e}", "completed render fails verification: {e}"),656
("GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}", "GPU map winner mismatch: color {:?} depth {} normal {:?} spec {}"),657
("GPU map winner depends on submission order", "GPU map winner depends on submission order"),658
("missing probe program", "missing probe program"),659
("diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}", "diffuse is lit or alpha/vertex/texture lost under {light:?}: {pixel:?} vs {expected:?}"),660
("target map dimensions/normals invalid", "target map dimensions/normals invalid"),661
("flat normals are not geometry-derived: {:?}", "flat normals are not geometry-derived: {:?}"),662
("Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives", "Production GPU maps PASS: nearest depth=0.25, normal=[128,128,255], specular=191, albedo texture×vertex={expected:?}, alpha preserved, diffuse identical under 3 opposed lights; target maps valid; missing source normals use actual GPU triangle derivatives"),663
("renderkader: hoogte 0.1–10 m, centrum/pivot -10–10 m, dieptebereik 0,1–19 m", "render frame: height 0.1–10 m, center/pivot -10–10 m, depth range 0.1–19 m"),664
("dit project gebruikt format_version {found}; deze build leest alleen versie {supported}", "this project uses format_version {found}; this build reads only version {supported}"),665
("het projectbestand heeft een veld dat versie {FORMAT_VERSION} niet kent ({detail}). Het bestand is niet herschreven.", "the project file has a field that version {FORMAT_VERSION} does not know ({detail}). The file was not rewritten."),666
("het projectbestand is geen geldige versie {FORMAT_VERSION}: {detail}", "the project file is not a valid version {FORMAT_VERSION}: {detail}"),667
("{message} ({path})", "{message} ({path})"),668
("dit project gebruikt format_version {}; deze build leest alleen versie {FORMAT_VERSION}", "this project uses format_version {}; this build reads only version {FORMAT_VERSION}"),669
("het project-id moet 1 tot 64 tekens zijn: letters, cijfers, '-' of '_'", "the project id must be 1 to 64 characters: letters, digits, '-' or '_'"),670
("de projectnaam moet 1 tot 80 tekens zijn, zonder stuurtekens", "the project name must be 1 to 80 characters, without control characters"),671
("het mannelijke model", "the male model"),672
("het vrouwelijke model", "the female model"),673
("maximaal {MAX_PROJECT_PRESETS} equipmentpresets per project", "at most {MAX_PROJECT_PRESETS} equipment presets per project"),674
("de preset '{preset}' is niet genormaliseerd", "the preset '{preset}' is not normalized"),675
("de preset '{clean}' staat dubbel in het project", "the preset '{clean}' appears twice in the project"),676
("een equipment-preset mag niet 'body' heten; dat is gereserveerd voor het lichaam", "an equipment preset may not be called 'body'; that is reserved for the body"),677
("character.ppd is het projectbestand, geen preset", "character.ppd is the project file, not a preset"),678
("preset '{preset}': {message}", "preset '{preset}': {message}"),679
("de uitvoermap is niet genormaliseerd", "the output folder is not normalized"),680
("de uitvoermap moet een submap zijn, niet de projectmap zelf", "the output folder must be a subfolder, not the project folder itself"),681
("de uitvoermap mag niet het .ppd-bestand zijn", "the output folder may not be the .ppd file"),682
("de uitvoermap '{clean}' mag de bron '{source}' niet bevatten", "the output folder '{clean}' may not contain the source '{source}'"),683
("uitvoermap: {message}", "output folder: {message}"),684
("de geschatte renderbuffer is {bytes} bytes; het maximum in deze versie is {MAX_ESTIMATE_BYTES}", "the estimated render buffer is {bytes} bytes; the maximum in this version is {MAX_ESTIMATE_BYTES}"),685
("breedte en hoogte lopen buiten de geheugenschatting van deze versie", "width and height exceed the memory estimate of this version"),686
("breedte en hoogte moeten groter zijn dan 0", "width and height must be greater than 0"),687
("breedte en hoogte mogen hoogstens {MAX_DIMENSION} zijn", "width and height may be at most {MAX_DIMENSION}"),688
("fps moet groter zijn dan 0", "fps must be greater than 0"),689
("fps mag hoogstens {MAX_FPS} zijn", "fps may be at most {MAX_FPS}"),690
("de wortel moet een object zijn", "the root must be an object"),691
("format_version ontbreekt", "format_version missing"),692
("format_version moet een geheel getal zijn", "format_version must be an integer"),693
("unknown field", "unknown field"),694
("geef het project een naam", "give the project a name"),695
("de projectnaam is te lang (maximaal 100 tekens)", "the project name is too long (at most 100 characters)"),696
("de projectnaam mag geen / \\ < > : \" | ? * of stuurtekens bevatten", "the project name may not contain / \\ < > : \" | ? * or control characters"),697
("de projectnaam mag niet op een punt eindigen", "the project name may not end in a dot"),698
("kies eerst een bestaande map waarin het project komt", "choose an existing folder for the project first"),699
("de map '{name}' bestaat al en is niet leeg; kies een andere naam", "the folder '{name}' already exists and is not empty; choose another name"),700
("er bestaat al een bestand '{name}'; kies een andere naam", "a file '{name}' already exists; choose another name"),701
("de projectmap is een bestand", "the project folder is a file"),702
("er staat al een character.ppd; open dat project in plaats van het te overschrijven", "there is already a character.ppd; open that project instead of overwriting it"),703
("kies een .ppd-bestand", "choose a .ppd file"),704
("openen is mislukt: {err}. Er is niets gewijzigd", "opening failed: {err}. Nothing was changed"),705
("kies een regulier .ppd-bestand", "choose a regular .ppd file"),706
("projectmanifest groter dan 4 MiB", "project manifest larger than 4 MiB"),707
("het .ppd-bestand heeft geen projectmap", "the .ppd file has no project folder"),708
("{label} mag niet character.ppd zijn", "{label} may not be character.ppd"),709
("{label} is niet genormaliseerd: '{value}'", "{label} is not normalized: '{value}'"),710
("{label}: {message}", "{label}: {message}"),711
("het pad is leeg", "the path is empty"),712
("gebruik '/' in projectpaden", "use '/' in project paths"),713
("een absoluut pad hoort niet in het .ppd-bestand; kies een pad binnen de projectmap", "an absolute path does not belong in the .ppd file; choose a path inside the project folder"),714
("het pad mag niet buiten de projectmap wijzen en geen lege segmenten hebben", "the path may not point outside the project folder and may not have empty segments"),715
("het pad bevat een leeg teken", "the path contains a NUL character"),716
("de presetnaam '{stem}' moet kleine letters, cijfers, '-' of '_' zijn, zonder spaties", "the preset name '{stem}' must be lowercase letters, digits, '-' or '_', without spaces"),717
("de uitvoermap '{}' is een bestand", "the output folder '{}' is a file"),718
("de uitvoermap '{}' bestaat nog niet", "the output folder '{}' does not exist yet"),719
("het bestand ontbreekt in de projectmap: {relative}", "the file is missing from the project folder: {relative}"),720
("het pad '{relative}' wijst buiten de projectmap", "the path '{relative}' points outside the project folder"),721
("de uitvoermap '{relative}' is een bestand", "the output folder '{relative}' is a file"),722
("het pad wijst buiten de projectmap", "the path points outside the project folder"),723
("het .ppd-bestand heeft geen map", "the .ppd file has no folder"),724
("het .ppd-bestand heeft geen naam", "the .ppd file has no name"),725
("opslaan is mislukt: {err}. Het vorige bestand is blijven staan", "saving failed: {err}. The previous file was kept"),726
("Geannuleerd; gedeeltelijke uitvoer behouden", "Cancelled; partial output kept"),727
("raw files", "raw files"),728
("opschonen geweigerd: render is niet voltooid", "cleanup refused: render is not complete"),729
("opschonen geweigerd: run.lock is bezet", "cleanup refused: run.lock is in use"),730
("opschonen {name}: {e}", "cleanup {name}: {e}"),731
("opschonen complete.json: {e}", "cleanup complete.json: {e}"),732
("run.lock is geen regulier bestand", "run.lock is not a regular file"),733
("deze run wordt op dit moment al gerenderd (run.lock bezet)", "this run is already being rendered (run.lock in use)"),734
("run.lock: {e}", "run.lock: {e}"),735
("progress.jsonl is geen regulier bestand", "progress.jsonl is not a regular file"),736
("{} is geen regulier bestand", "{} is not a regular file"),737
("quarantaine van {rel} mislukt: {e}", "quarantining {rel} failed: {e}"),738
("geen vrije quarantainenaam voor {rel}", "no free quarantine name for {rel}"),739
("archiveren van {name} mislukt: {e}", "archiving {name} failed: {e}"),740
("geen vrije archiefnaam voor {name}", "no free archive name for {name}"),741
("render.json ontbreekt", "render.json missing"),742
("huidige instellingen/bronnen passen niet bij deze run (verschil in: {})", "current settings/sources do not match this run (differences in: {})"),743
("runmap is geen gewone map", "run folder is not a plain folder"),744
("runmap ligt niet direct in de uitvoermap", "run folder is not directly inside the output folder"),745
("deze run is al voltooid (complete.json bestaat)", "this run is already complete (complete.json exists)"),746
("journal noemt een onbekend pad: {unknown}", "journal names an unknown path: {unknown}"),747
("map staat op de plaats van PNG {rel}", "folder in the place of PNG {rel}"),748
("wordt op dit moment gerenderd", "is being rendered right now"),749
("past niet meer bij de huidige instellingen/bronnen (verschil in: {})", "no longer matches the current settings/sources (differences in: {})"),750
("oude onvoltooide render {name}: {e}", "old unfinished render {name}: {e}"),751
("oude resultaatmap opzijzetten: {e}", "moving the old result folder aside: {e}"),752
("nieuwe resultaatmap plaatsen: {e}", "placing the new result folder: {e}"),753
("vorige resultaatmap {final_name} hersteld", "previous result folder {final_name} restored"),754
("voltooide render {name} alsnog geplaatst als {final_name}", "completed render {name} placed as {final_name} after all"),755
("Laad een project of maak een nieuwe projectmap.", "Load a project or create a new project folder."),756
("maximaal 64 equipmentpresets per project", "at most 64 equipment presets per project"),757
("equipmentpresets samen groter dan 64 MiB", "equipment presets together larger than 64 MiB"),758
("ongeldige equipmentnaam", "invalid equipment name"),759
("de naam gebruikt kleine letters, cijfers, '-' of '_', en is niet 'body'", "the name uses lowercase letters, digits, '-' or '_', and is not 'body'"),760
("die equipmentnaam bestaat al", "that equipment name already exists"),761
("Projectmap aangemaakt, met de testlichamen in assets/body.", "Project folder created, with the test bodies in assets/body."),762
("Project aangemaakt, met de testlichamen in assets/body.", "Project created, with the test bodies in assets/body."),763
("Project geopend.", "Project opened."),764
("er is geen project om op te slaan", "there is no project to save"),765
("ongeldig projectpad", "invalid project path"),766
("het bestand ligt buiten de projectmap", "the file is outside the project folder"),767
("kies een regulier bestand", "choose a regular file"),768
("presetbestand overschrijdt het bestands-/projectbudget", "preset file exceeds the file/project budget"),769
("legacy preset; kies een OBJ- of FBX-model om te configureren", "legacy preset; choose an OBJ or FBX model to configure"),770
("dubbele equipmentnaam", "duplicate equipment name"),771
("equipment '{}' heeft geen geldige naam", "equipment '{}' has no valid name"),772
("legacy preset groter dan 64 KiB", "legacy preset larger than 64 KiB"),773
("de afbeelding heeft geen pixels", "the image has no pixels"),774
("het lichaam heeft geen mesh", "the body has no mesh"),775
("Aanmaken", "Create"),776
("Animatie", "Animation"),777
("Annuleren", "Cancel"),778
("Breedte", "Width"),779
("Depth-map", "Depth map"),780
("Equipment-configuratie", "Equipment configuration"),781
("Geslacht", "Sex"),782
("Glanssterkte", "Gloss strength"),783
("Grijs", "Gray"),784
("Hervat", "Resume"),785
("Hoogte", "Height"),786
("Lichaamsmodel", "Body model"),787
("Man", "Male"),788
("Map", "Folder"),789
("Naam", "Name"),790
("Nieuw", "New"),791
("Opgeslagen.", "Saved."),792
("Opslaan", "Save"),793
("Paperdoll-project", "Paperdoll project"),794
("Pauze", "Pause"),795
("RGB-banden", "RGB bands"),796
("Resultaat", "Result"),797
("Shinymap", "Shiny map"),798
("Snelheid", "Speed"),799
("Speel", "Play"),800
("Terug", "Back"),801
("Vrouw", "Female"),802
("Weergave", "View"),803
("Wijzigingen", "Changes"),804
("achterkant", "back"),805
("linkerzijde", "left side"),806
("rechterzijde", "right side"),807
("voorkant", "front"),808
("automatisch", "automatic"),809
("doelresolutie", "target resolution"),810
("man", "male"),811
("vrouw", "female"),812
("nieuw", "new"),813
("Instellingen", "Settings"),814
("Taal", "Language"),815
("Sluiten", "Close"),816
];@@ -21,6 +21,8 @@ pub mod export_set;
21
21
pub mod fbx_scene;
22
22
pub mod file_assoc;
23
23
pub mod foundation;
24
pub mod i18n;
25
pub mod i18n_table;
24
26
pub mod icon;
25
27
pub mod import;
26
28
pub mod preview;
@@ -21,6 +21,8 @@ pub mod export_set;21
pub mod fbx_scene;21
pub mod fbx_scene;22
pub mod file_assoc;22
pub mod file_assoc;23
pub mod foundation;23
pub mod foundation;24
pub mod i18n;25
pub mod i18n_table;24
pub mod icon;26
pub mod icon;25
pub mod import;27
pub mod import;26
pub mod preview;28
pub mod preview;@@ -90,7 +90,10 @@ impl Viewport {
90
90
}
91
91
for clip in &character.clips {
92
92
if ui
93
.selectable_label(controls.clip_name == clip.name, &clip.name)
93
.selectable_label(
94
controls.clip_name == clip.name,
95
crate::i18n::t(&clip.name),
96
)
94
97
.clicked()
95
98
{
96
99
controls.select_clip(&clip.name);
@@ -102,9 +105,9 @@ impl Viewport {
102
105
} else {
103
106
"Geen animaties: bindpose"
104
107
};
105
ui.add_enabled(false, egui::Button::new("Animatie"))
106
.on_disabled_hover_text(why);
107
ui.label(why);
108
ui.add_enabled(false, egui::Button::new(crate::i18n::t("Animatie")))
109
.on_disabled_hover_text(crate::i18n::t(why));
110
ui.label(crate::i18n::t(why));
108
111
}
109
112
}
110
113
let animated = character.is_some_and(|c| !c.clips.is_empty());
@@ -120,24 +123,27 @@ impl Viewport {
120
123
{
121
124
controls.playing = !controls.playing;
122
125
}
123
if ui.button("Yaw −").clicked() {
126
if ui.button(crate::i18n::t("Yaw −")).clicked() {
124
127
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);
125
128
}
126
if ui.button("Yaw +").clicked() {
129
if ui.button(crate::i18n::t("Yaw +")).clicked() {
127
130
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);
128
131
}
129
132
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {
130
if ui.button(format!("pitch {pitch:.0}")).clicked() {
133
if ui
134
.button(crate::i18n::t(format!("pitch {pitch:.0}")))
135
.clicked()
136
{
131
137
controls.snap_pitch_row(index as i32);
132
138
}
133
139
}
134
ui.label(format!(
140
ui.label(crate::i18n::t(format!(
135
141
"yaw {:.0}° / {} pitch {:.0}°",
136
142
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg
137
));
143
)));
138
144
});
139
145
ui.label(
140
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws.",
146
crate::i18n::t("Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws."),
141
147
);
142
148
let available = ui.available_rect_before_wrap();
143
149
let rect = egui::Rect::from_min_size(
@@ -90,7 +90,10 @@ impl Viewport {90
}90
}91
for clip in &character.clips {91
for clip in &character.clips {92
if ui92
if ui93
.selectable_label(controls.clip_name == clip.name, &clip.name)93
.selectable_label(94
controls.clip_name == clip.name,95
crate::i18n::t(&clip.name),96
)94
.clicked()97
.clicked()95
{98
{96
controls.select_clip(&clip.name);99
controls.select_clip(&clip.name);@@ -102,9 +105,9 @@ impl Viewport {102
} else {105
} else {103
"Geen animaties: bindpose"106
"Geen animaties: bindpose"104
};107
};105
ui.add_enabled(false, egui::Button::new("Animatie"))108
ui.add_enabled(false, egui::Button::new(crate::i18n::t("Animatie")))106
.on_disabled_hover_text(why);109
.on_disabled_hover_text(crate::i18n::t(why));107
ui.label(why);110
ui.label(crate::i18n::t(why));108
}111
}109
}112
}110
let animated = character.is_some_and(|c| !c.clips.is_empty());113
let animated = character.is_some_and(|c| !c.clips.is_empty());@@ -120,24 +123,27 @@ impl Viewport {120
{123
{121
controls.playing = !controls.playing;124
controls.playing = !controls.playing;122
}125
}123
if ui.button("Yaw −").clicked() {126
if ui.button(crate::i18n::t("Yaw −")).clicked() {124
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);127
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 - 1);125
}128
}126
if ui.button("Yaw +").clicked() {129
if ui.button(crate::i18n::t("Yaw +")).clicked() {127
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);130
controls.snap_yaw_index((controls.yaw_deg / 30.0).round() as i32 + 1);128
}131
}129
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {132
for (index, pitch) in PAPERDOLL_PITCHES_DEG.iter().enumerate() {130
if ui.button(format!("pitch {pitch:.0}")).clicked() {133
if ui134
.button(crate::i18n::t(format!("pitch {pitch:.0}")))135
.clicked()136
{131
controls.snap_pitch_row(index as i32);137
controls.snap_pitch_row(index as i32);132
}138
}133
}139
}134
ui.label(format!(140
ui.label(crate::i18n::t(format!(135
"yaw {:.0}° / {} pitch {:.0}°",141
"yaw {:.0}° / {} pitch {:.0}°",136
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg142
controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg137
));143
)));138
});144
});139
ui.label(145
ui.label(140
"Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws.",146
crate::i18n::t("Sleep om te draaien, scroll om te zoomen. Yaw 0 kijkt op de voorkant (+Z, glTF-standaard). De knoppen zetten de vijf pitches en de twaalf yaws."),141
);147
);142
let available = ui.available_rect_before_wrap();148
let available = ui.available_rect_before_wrap();143
let rect = egui::Rect::from_min_size(149
let rect = egui::Rect::from_min_size(