BlueGit

golden-age paperdoll-renderer

Merge feature/i18n (Tim t1004u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
598c4cf529e94564c3a2a8aed3f65355bcc989e7
Pipeline
Passed
6 files changed +1647 −300
README.md +105 −102
@@ -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 ![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
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 ![Body view with animation and camera controls](docs/screenshots/body-live.png)
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 ![2D-preview op pitch +60 met een echte FBX-export via .ppda en atlassen](docs/screenshots/preview2d-ppda.png)
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 ![2D preview at pitch +60 with a real FBX export via .ppda and atlases](docs/screenshots/preview2d-ppda.png)
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 ![Werkelijke equipment-editor met geanimeerd lichaam en handgebonden OBJ](docs/screenshots/equipment-live.png)
92 ![Real equipment editor with animated body and hand-bound OBJ](docs/screenshots/equipment-live.png)
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 ![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
165 ![Resuming an interrupted run in the body view](docs/screenshots/body-resume.png)
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 Renderer
1# Paperdoll Renderer
2
2
3Desktopeditor voor `.ppd`-projecten, bone-gebonden equipment en geanimeerde 3D-preview, met productie-export naar paperdoll-maps.
3Desktop 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
7Rust 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.
7Rust 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```bash
9```bash
10cargo run
10cargo run
11cargo run -- "/pad met spaties/character.ppd"
11cargo run -- "/path with spaces/character.ppd"
12cargo run -- --preview-fixture
12cargo run -- --preview-fixture
13cargo run -- --status
13cargo run -- --status
14```
14```
15
15
16## Project en bodies
16## Language and settings
17
17
18Het 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.
18The 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
20De 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:
22The 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.
24The 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![Bodyweergave met animatie- en camerabediening](docs/screenshots/body-live.png)
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
35Een 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![Body view with animation and camera controls](docs/screenshots/body-live.png)
36
36
37### Statische lichamen (OBJ, glTF/FBX zonder skin)
37### Multiple meshes and skins (glTF/GLB)
38
38
39Een 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:
39A 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-preview
43A 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
49Vanuit **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 preview
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.
58
52
59![2D-preview op pitch +60 met een echte FBX-export via .ppda en atlassen](docs/screenshots/preview2d-ppda.png)
53From **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![2D preview at pitch +60 with a real FBX export via .ppda and atlases](docs/screenshots/preview2d-ppda.png)
60
64
61```bash
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
63cargo run --example preview2d_smoke --locked -- /tmp/preview2d-smoke
67cargo 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
65cargo run --example preview2d_smoke --locked -- /tmp/preview2d-real /tmp/fbx-live
69cargo 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
70Vanuit een body opent **Equipment** de lijst. Kies een definitie of **Nieuw**.
74From a body, **Equipment** opens the list. Choose a definition or **New**.
71
75
721. Geef een unieke naam van maximaal 64 kleine letters/cijfers/`-`/`_`, zonder spaties; `body` is gereserveerd.
761. Give a unique name of at most 64 lowercase letters/digits/`-`/`_`, no spaces; `body` is reserved.
732. Kies **Man / Vrouw** voor het lichaam dat je bekijkt; **Geldig voor** bepaalt op welke geslachten het preset van toepassing is.
772. Choose **Male / Female** for the body you are viewing; **Valid for** decides which sexes the preset applies to.
743. **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.
783. **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.
754. 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.
794. 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.
765. **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.
805. **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.
776. **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.
816. **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
81Een 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`.
85A 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```bash
87```bash
84# Zelfbevattend CC0-zwaardvoorbeeld in een nieuw tijdelijk project
88# Self-contained CC0 sword sample in a new temporary project
85cargo run --example equipment_fixture --locked
89cargo run --example equipment_fixture --locked
86```
90```
87
91
88![Werkelijke equipment-editor met geanimeerd lichaam en handgebonden OBJ](docs/screenshots/equipment-live.png)
92![Real equipment editor with animated body and hand-bound OBJ](docs/screenshots/equipment-live.png)
89
93
90Bij 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.
94When 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
98Alle 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.
102All 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
106De 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.
110The 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
108Per 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.
112Per 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
110Het 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:
114The 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.
114Alpha 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.
118Alpha 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```text
120```text
117renders/male-body-atlas/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
121renders/male-body-atlas/raw files/male/body/clip001-walk/f000012/p-60_y330_diffuse.png
118renders/male-body-atlas/male-body-diffuse-atlas.png (en -normal, -depth, -specular)
122renders/male-body-atlas/male-body-diffuse-atlas.png (and -normal, -depth, -specular)
119renders/male-body-atlas/male-body-atlas.ppda
123renders/male-body-atlas/male-body-atlas.ppda
120```
124```
121
125
122Per 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.
126Per 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
124Een voltooide resultaatmap bevat alleen uitvoer voor de gebruiker:
128A finished result folder contains only consumer output:
125
129
126```
130```
127renders/male-body-atlas/
131renders/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
137Alle 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.
141All 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
139Tijdens 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`.
143While 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
143Als 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).
147When 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### Hervatten
155### Resuming
152
156
153Een 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.
157A 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![Onderbroken run hervatten in de bodyweergave](docs/screenshots/body-resume.png)
165![Resuming an interrupted run in the body view](docs/screenshots/body-resume.png)
162
166
163## 2D depth-compositor (bibliotheek)
167## 2D depth compositor (library)
164
168
165De 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).
169The `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```rust
173```rust
170use paperdoll_renderer::compositor::{
174use paperdoll_renderer::compositor::{
@@ -181,45 +185,44 @@ let layers = [
181];
185];
182let (maps, lit_rgba) = compose_and_relight(&layers, &Light::default())?;
186let (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):
185use paperdoll_renderer::atlas::AtlasSet;
189use paperdoll_renderer::atlas::AtlasSet;
186let body = AtlasSet::open_run(&body_run_dir)?.load()?;
190let body = AtlasSet::open_run(&body_run_dir)?.load()?;
187let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
191let layer = LayerMaps::from_atlas(&body, "body", "clip000-walk", 0, 60, 330)?;
188```
192```
189
193
194Highres 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
191Highres 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 with
192
193## Openen met
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).
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
199Het 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).
202The 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## Tests
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```bash
208```bash
206cargo fmt --check
209cargo fmt --check
207cargo check --workspace --all-targets --locked
210cargo check --workspace --all-targets --locked
208cargo clippy --workspace --all-targets --locked -- -D warnings
211cargo clippy --workspace --all-targets --locked -- -D warnings
209cargo test --workspace --locked # incl. tests/robustness.rs
212cargo 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
211xvfb-run -a cargo run --example preview_depth_probe --locked
214xvfb-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
213xvfb-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
216xvfb-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
215cargo 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
218cargo 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
216xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
219xvfb-run -a cargo run --release --example resume_fixture --locked -- render /tmp/resume male --abort-after=200
217xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
220xvfb-run -a cargo run --release --example resume_fixture --locked -- resume /tmp/resume male
218cargo run --release --example resume_fixture --locked -- verify /tmp/resume
221cargo 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
220xvfb-run -a cargo run --example fbx_live_fixture --locked -- /tmp/fbx-live /tmp/fbx-live-proof 32 48 4
223xvfb-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
222xvfb-run -a -s '-screen 0 1280x1024x24' cargo run --example static_body_fixture --locked -- /tmp/static-body /tmp/static-body-proof 32 48 4
225xvfb-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
225De 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`.
228The 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`.
src/editor.rs +279 −188
@@ -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 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 pub fn recent_projects(&self) -> &[PathBuf] {
212 pub fn recent_projects(&self) -> &[PathBuf] {
171 &self.recent
213 &self.recent
172 }
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 ui
944 if ui
903 .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.planned
968 candidate.name, candidate.state, candidate.journaled, candidate.planned
927 ));
969 )));
928 match &candidate.blocked {
970 match &candidate.blocked {
929 None => {
971 None => {
930 if ui
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 .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 ui
1112 if ui
1068 .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 ui
1123 if ui
1079 .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 ui
1134 if ui
1090 .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 && ui
1145 && ui
1101 .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 ui
1159 if ui
1113 .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 = diagnostic
1180 let auto_range = diagnostic
1131 .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 ui
1187 if ui
1138 .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 ..*camera
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 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 {
1221impl Default for EditorApp {
1273impl 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 app
1280 app
1226 }
1281 }
@@ -1279,7 +1334,7 @@ impl eframe::App for EditorApp {
1279 } else {
1334 } else {
1280 egui::Color32::LIGHT_RED
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 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 ui
1413 if ui
1351 .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 ui
1463 if ui
1398 .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 = self
1493 let path = self
1428 .session
1494 .session
1429 .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_count
1517 mesh.name, mesh.vertex_count, mesh.triangle_count
1452 ));
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.height
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 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_seconds
1534 )));
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 ui
1568 if ui
1500 .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.viewport
1604 self.viewport
1536 .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_label
1619 self.render_ui.preview_label
1551 ));
1620 )));
1552 if self.pass_texture.is_none()
1621 if self.pass_texture.is_none()
1553 || self.pass_texture_revision != self.render_ui.preview_revision
1622 || self.pass_texture_revision != self.render_ui.preview_revision
1554 {
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 ui
1682 if ui
1614 .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 ui
1692 if ui
1624 .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 .shiny
1820 .shiny
1752 .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 ui
1903 if ui
1835 .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.viewport
1951 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 ui
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 .clicked()
2098 .clicked()
2030 {
2099 {
2031 set_sex = Some(Sex::Male);
2100 set_sex = Some(Sex::Male);
2032 }
2101 }
2033 if ui
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 .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 = self
2117 let reasons = self
2049 .preview2d_skipped
2118 .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<_> = self
2148 let clips: Vec<_> = self
2080 .preview2d_catalog
2149 .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 ui
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 .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_secs
2188 self.preview2d.elapsed_secs
2120 ));
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 ui
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 .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 ui
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 .clicked()
2439 .clicked()
2365 {
2440 {
2366 self.begin(Intent::PickNew);
2441 self.begin(Intent::PickNew);
2367 }
2442 }
2368 if ui
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 .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 ui
2485 if ui
2408 .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}
src/i18n.rs added +429 −0
@@ -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 UI
4//! boundary through [`crate::i18n_table::TABLE`]. Entries may be format
5//! templates whose `{…}` holes are matched and translated recursively.
6use std::collections::HashMap;
7use std::path::Path;
8use std::sync::atomic::{AtomicU8, Ordering};
9use std::sync::{Mutex, OnceLock};
10
11use crate::i18n_table::TABLE;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum Lang {
15 #[default]
16 En,
17 Nl,
18}
19
20impl 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
40static LANG: AtomicU8 = AtomicU8::new(0);
41
42pub fn lang() -> Lang {
43 if LANG.load(Ordering::Relaxed) == 1 {
44 Lang::Nl
45 } else {
46 Lang::En
47 }
48}
49
50pub fn set_lang(lang: Lang) {
51 LANG.store(u8::from(lang == Lang::Nl), Ordering::Relaxed);
52}
53
54/// Display text in the current language.
55pub fn t(text: impl AsRef<str>) -> String {
56 tr(lang(), text.as_ref())
57}
58
59/// Display text in `lang`.
60pub 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
67struct 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
75struct 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.
81fn 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
112fn 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).
141fn 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
179fn 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
195fn 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.
235pub const SETTINGS_FILE: &str = "settings.json";
236
237/// Read the stored language (English when missing or unreadable).
238pub 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.
247pub 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)]
257mod 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}
src/i18n_table.rs added +816 −0
@@ -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 a
2//! source string or format template; `{…}` holes are matched at runtime.
3//! Regenerate entries when UI/error text changes; `i18n` tests enforce coverage.
4pub 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];
src/lib.rs +2 −0
@@ -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;
21pub mod fbx_scene;
21pub mod fbx_scene;
22pub mod file_assoc;
22pub mod file_assoc;
23pub mod foundation;
23pub mod foundation;
24pub mod i18n;
25pub mod i18n_table;
24pub mod icon;
26pub mod icon;
25pub mod import;
27pub mod import;
26pub mod preview;
28pub mod preview;
src/preview_gl.rs +16 −10
@@ -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 ui
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 .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 ui
134 .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_deg
142 controls.yaw_deg, PAPERDOLL_YAW_COUNT, controls.pitch_deg
137 ));
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(