Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d64baa01 | ||
|
|
151630e957 | ||
|
|
1b319c48b7 | ||
|
|
a478bfbf35 | ||
|
|
702f54ae53 | ||
|
|
bbf4693475 | ||
|
|
d7b0203502 | ||
|
|
da876e3545 |
No files matched your search
@@ -145,6 +145,10 @@ For each task:
|
|||||||
5. Update documentation and `MEMORY.md` if the change creates lasting context.
|
5. Update documentation and `MEMORY.md` if the change creates lasting context.
|
||||||
6. Mark the task complete.
|
6. Mark the task complete.
|
||||||
7. Move its section to `DONE.md` when the entire section is complete.
|
7. Move its section to `DONE.md` when the entire section is complete.
|
||||||
|
8. Commit the changes to the version control system with a descriptive message.
|
||||||
|
9. If a section from `TODO.md` has been fully completed, create a new version or tag in the version control system to mark the milestone.
|
||||||
|
10. Push the committed changes to the shared repository if applicable.
|
||||||
|
11. Note the new version or tag created in the version control system in any relevant documentation or handoff summary.
|
||||||
|
|
||||||
Avoid unrelated refactors unless they are required for the task or explicitly approved.
|
Avoid unrelated refactors unless they are required for the task or explicitly approved.
|
||||||
|
|
||||||
|
|||||||
@@ -1196,3 +1196,49 @@ Web deployment and release artifact list were brought up to date. `MEMORY.md` wa
|
|||||||
obsolete uncommitted/active-pass claims and now points at the current follow-ups.
|
obsolete uncommitted/active-pass claims and now points at the current follow-ups.
|
||||||
|
|
||||||
- [x] Re-check `PLAN.md`/`MEMORY.md` against the current build, update `README.md` and `docs/architecture.md`, and compact the historical PLAN sections.
|
- [x] Re-check `PLAN.md`/`MEMORY.md` against the current build, update `README.md` and `docs/architecture.md`, and compact the historical PLAN sections.
|
||||||
|
|
||||||
|
## NPC gear fit (v0.6.1)
|
||||||
|
|
||||||
|
Completed 2026-11-01. Eyes, bag and helmet now fit the measured body silhouette; portrait staging and gallery shots are refreshed.
|
||||||
|
|
||||||
|
The 2026-11-01 detail pass fixed the bodies but not the props worn on them. In the portrait
|
||||||
|
capture (`--stages=day,portrait`, see the report in `docs/screens/realism/2026-11-01-npc/`) the
|
||||||
|
sunglasses span the whole upper face and the shoulder bag cuts through `npc_coat`'s skirt. Blender sources for gear
|
||||||
|
live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blender-assets.md`
|
||||||
|
(gear budget 1500 tris, `GEAR_ATTACH` points in `NpcAvatar`).
|
||||||
|
|
||||||
|
- [x] Measure the fit in Blender, not by eye: `python3 tools/blender_assets.py fit` prints each
|
||||||
|
character's measured head width and hip/skirt radius, then every head/back/chest gear
|
||||||
|
bounding box with head-band status or skirt clearance. Current measurements: head width
|
||||||
|
0.315 m; coat skirt 0.225 m, dress 0.260 m; sunglasses 0.282 m (0.90x head); shoulder-bag
|
||||||
|
x-clearance -0.025 m on the coat (radial -0.031 m on the dress).
|
||||||
|
- [x] Narrow `sunglasses` from 0.28 m to ~0.20 m. The hinges moved to ±0.075 (0.035 box), the
|
||||||
|
arms to ±0.098, and the lenses are 0.024 m radius at the same `y=-0.152`, `z=0.035` plane.
|
||||||
|
Rebuilt GLB/AO: measured width **0.202 m** (0.64x head), still inside the head band.
|
||||||
|
Also fixed `capture_realism.gd` so `--portrait-type=N` is actually parsed (it was documented
|
||||||
|
but ignored).
|
||||||
|
- [x] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: narrowed the bag to 0.06 m and
|
||||||
|
moved it outboard (centre x=0.292); the body's inner face is now 0.262 m, clearing the coat
|
||||||
|
(0.225) by 0.037 m and the dress (0.260) by 0.002 m. The strap's lower attach points sit on
|
||||||
|
the same outboard line. `fit` now measures the bag body separately from the torso strap.
|
||||||
|
- [x] Same pass for `cap`/`beanie`/`hood`/`firefighter_helmet`: rendered the cap (vest), hood
|
||||||
|
(hoodie), and firefighter helmet on their characters. Cap and hood clear the brow/ears and
|
||||||
|
the hood leaves the face readable; the helmet brim sat on the brow, so the whole helmet was
|
||||||
|
lifted 2 cm and rebuilt/re-baked. Beanie uses the same capped-shell sizing as the cap and is
|
||||||
|
currently unused; it is covered by the upcoming headgear regression check.
|
||||||
|
- [x] Rebuild the gear (`build --only <18 gear names>`) and re-bake AO (`tools/bake_ao.py --only
|
||||||
|
<same>`), then `godot --headless --path . --import` (clean). Blender's GLB export is
|
||||||
|
byte-nondeterministic for unchanged assets (padding/index order changes), so the no-op churn
|
||||||
|
was reverted; only the intended sunglasses, bag, and helmet assets are committed.
|
||||||
|
- [x] Add a regression test: `tools/test_blender_assets.py` limits the glasses to 0.70x head
|
||||||
|
width and requires positive bag clearance on both coat and dress;
|
||||||
|
`test_npc_character.test_head_and_back_gear_clear_the_body_silhouette` loads the gear on
|
||||||
|
six roles and checks head-parent/width/brow bounds plus back-parent/0.26 m outer radius.
|
||||||
|
- [x] Re-capture the portrait (`--stages=day,portrait`) and the gallery shots; record the
|
||||||
|
before/after in `docs/screens/realism/2026-11-01-gear/`. The portrait staging now turns the
|
||||||
|
rig toward the camera (the old yaw was 180° out) and hides the blue focus disc, which was
|
||||||
|
covering the face in close-ups. `check_screenshots.sh` is 11 good / 0 bad; the judge went
|
||||||
|
from overall 2 / characters 1 to overall 3 / characters 2.
|
||||||
|
- [x] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too.
|
||||||
|
`--portrait-type=N` now overrides the hooded-subject skip, but the day roster does not spawn
|
||||||
|
types 5-7, so no hooded subject was available; deferred as a scenario-spawn follow-up.
|
||||||
@@ -13,6 +13,16 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
(46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked.
|
(46 `.blend` files removed from the index on 2026-11-01); the `.gdignore` marker stays tracked.
|
||||||
- `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01
|
- `screen_designs.py judge` default `google/gemini-2.5-flash-lite` worked on 2026-11-01
|
||||||
(7 match / 4 review / 0 diff); `--model` remains available.
|
(7 match / 4 review / 0 diff); `--model` remains available.
|
||||||
|
- `capture_realism.gd` parses `--portrait-type=N`; it was documented but ignored before the gear
|
||||||
|
pass. Portrait captures need the requested NPC type to have spawned, otherwise the stage can
|
||||||
|
finish with an empty frame; use the Blender character previews (`render --only npc_coat`) for
|
||||||
|
deterministic gear checks.
|
||||||
|
- Headgear: the firefighter helmet was lifted 2 cm after the brow/face pass; cap and hood were
|
||||||
|
visually checked on `npc_vest`/`npc_hoodie`. The beanie is unused and shares the cap shell.
|
||||||
|
- Gear fit is measured with `python3 tools/blender_assets.py fit` (stdlib, reads the GLBs):
|
||||||
|
head width uses skin vertices weighted to Head in y 1.55–1.81; clothing radius is the cloth
|
||||||
|
skirt in y 0.30–0.52 with a pants hip fallback. The command reports per-character gear boxes,
|
||||||
|
head-band status and x/radial skirt clearance; `test_blender_assets` covers its shape.
|
||||||
- Post-realism work is split by theme, not by date: the finished lighting/flashlight/NPC items
|
- Post-realism work is split by theme, not by date: the finished lighting/flashlight/NPC items
|
||||||
are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit,
|
are archived in `DONE.md`, and `TODO.md` carries three planned workstreams (NPC gear fit,
|
||||||
venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added
|
venue geometry + committed gallery, Forward+ desktop preset). New visual work should be added
|
||||||
@@ -231,6 +241,15 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
same colliders/obstacles, and collects library emissives for `set_night_mode()`.
|
same colliders/obstacles, and collects library emissives for `set_night_mode()`.
|
||||||
`LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from
|
`LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from
|
||||||
`PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern).
|
`PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern).
|
||||||
|
**Gear fit (v0.6.1):** `fit` measures head width (0.315 m), skirt/hip radius and gear bounds;
|
||||||
|
sunglasses are 0.202 m, the bag's body inner face is 0.262 m (coat 0.225, dress 0.260), and the
|
||||||
|
helmet is lifted 2 cm above the brow. `test_npc_character` guards head/back gear in Godot.
|
||||||
|
Blender's GLB export is byte-nondeterministic for unchanged assets (padding/index order), so do
|
||||||
|
not commit a full no-op rebuild; rebuild only the assets whose geometry changed.
|
||||||
|
- **Portrait capture:** `capture_realism.gd` faces the **Rig** toward the camera (using the
|
||||||
|
inverse look direction) and hides the blue `FocusCircle`; the old code used the wrong yaw and
|
||||||
|
left the focus disc over the face. An explicit `--portrait-type=N` now overrides the hooded
|
||||||
|
skip, but the day roster spawns none of types 5-7, so a hooded close-up is deferred.
|
||||||
- **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset
|
- **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset
|
||||||
(`<name>_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in;
|
(`<name>_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in;
|
||||||
`tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt
|
`tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt
|
||||||
|
|||||||
@@ -3,39 +3,6 @@
|
|||||||
Work is grouped by theme. Each item should be small enough to complete and verify on its own;
|
Work is grouped by theme. Each item should be small enough to complete and verify on its own;
|
||||||
see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these plans.
|
see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these plans.
|
||||||
|
|
||||||
## NPC gear fit (v0.6.1)
|
|
||||||
|
|
||||||
The 2026-11-01 detail pass fixed the bodies but not the props worn on them. In the portrait
|
|
||||||
capture (`--stages=day,portrait`, see the report in `docs/screens/realism/2026-11-01-npc/`) the
|
|
||||||
sunglasses span the whole upper face and the shoulder bag cuts through `npc_coat`'s skirt. Blender sources for gear
|
|
||||||
live in `tools/blender_assets.py` (`_build_gear()`), the contract in `docs/blender-assets.md`
|
|
||||||
(gear budget 1500 tris, `GEAR_ATTACH` points in `NpcAvatar`).
|
|
||||||
|
|
||||||
- [ ] Measure the fit in Blender, not by eye: extend `blender_assets.py audit` (or add a `fit`
|
|
||||||
subcommand) to print, per head/back/chest gear piece, its bounding box against the avatar
|
|
||||||
head width (measured 0.314 m on `npc_coat`), the head height band (y 1.55–1.81) and the
|
|
||||||
clothing radius (coat/dress skirt flare 0.225–0.26 m), for every character variant.
|
|
||||||
- [ ] Narrow `sunglasses` from 0.28 m to ~0.20 m. The width is driven by the arms/hinges
|
|
||||||
(`Hinge` at `x = ±0.11` with a 0.06 box, `Arm` at `±0.138`), not by the 0.03 m lenses, so
|
|
||||||
pull those in first and shrink the lenses to ~0.024 m radius; keep the lens plane at
|
|
||||||
`y = -0.152` (in front of the sclera) and `z = 0.035` (eye line) so it does not ride up
|
|
||||||
over the new brow ridge.
|
|
||||||
- [ ] Fix `shoulder_bag` clipping through `npc_coat`/`npc_dress`: the bag body spans `|x|`
|
|
||||||
0.17–0.25 at the hip while the skirt flares to 0.225–0.26, so its inner half is inside the
|
|
||||||
cloth. Move it outboard (or up onto the belt line) until the inner face clears the flare,
|
|
||||||
and keep the strap tube outside the same radius.
|
|
||||||
- [ ] Same pass for `cap`/`beanie`/`hood`/`firefighter_helmet`: they must not clip the new
|
|
||||||
brow/ear geometry, and `hood` must still leave the face readable for hoodie avatars.
|
|
||||||
- [ ] Rebuild the gear (`build`) and re-bake AO (`tools/bake_ao.py`), then
|
|
||||||
`godot --headless --path . --import`.
|
|
||||||
- [ ] Add a regression test: `tools/test_blender_assets.py` checks the glasses width against the
|
|
||||||
head width, and `src/test/tests/test_npc_character.gd` checks the head/back attach nodes
|
|
||||||
stay outside the body silhouette radius.
|
|
||||||
- [ ] Re-capture the portrait (`--stages=day,portrait`) and the gallery shots; record the
|
|
||||||
before/after in `docs/screens/realism/<date>-gear/`.
|
|
||||||
- [ ] Consider a second character close-up for a hoodie/winter NPC so headgear is covered too
|
|
||||||
(`--portrait-type=5` already forces a hooded role for comparison).
|
|
||||||
|
|
||||||
## Venue geometry and the committed gallery (v0.6.1)
|
## Venue geometry and the committed gallery (v0.6.1)
|
||||||
|
|
||||||
Today all three venues share the Hollywood `WorldBuilder` geometry, so the Joke Shop and studio
|
Today all three venues share the Hollywood `WorldBuilder` geometry, so the Joke Shop and studio
|
||||||
@@ -86,6 +53,8 @@ path.
|
|||||||
|
|
||||||
## Realism follow-ups (deferred)
|
## Realism follow-ups (deferred)
|
||||||
|
|
||||||
|
- [ ] Capture a hooded/winter NPC close-up once a scenario spawns types 5-7 (the explicit
|
||||||
|
`--portrait-type` override is already in `capture_realism.gd`).
|
||||||
- [ ] Human-authored tree/hedge silhouettes: the whole scene now reads well except the foliage,
|
- [ ] Human-authored tree/hedge silhouettes: the whole scene now reads well except the foliage,
|
||||||
which is still flat-shaded clumps (`_shrub()`/`_tree()` in `tools/blender_assets.py`).
|
which is still flat-shaded clumps (`_shrub()`/`_tree()` in `tools/blender_assets.py`).
|
||||||
Only worth doing if a higher-poly organic builder is added to the library.
|
Only worth doing if a higher-poly organic builder is added to the library.
|
||||||
|
|||||||
@@ -287,12 +287,12 @@
|
|||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.26,
|
0.26,
|
||||||
0.225,
|
0.245,
|
||||||
0.26
|
0.26
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.26,
|
-0.26,
|
||||||
0.015,
|
0.035,
|
||||||
-0.351
|
-0.351
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1001,7 +1001,7 @@
|
|||||||
"attach": "back",
|
"attach": "back",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.25,
|
0.322,
|
||||||
0.348,
|
0.348,
|
||||||
0.139
|
0.139
|
||||||
],
|
],
|
||||||
@@ -1017,7 +1017,7 @@
|
|||||||
],
|
],
|
||||||
"path": "assets/models/blender/npc_gear/shoulder_bag.glb",
|
"path": "assets/models/blender/npc_gear/shoulder_bag.glb",
|
||||||
"triangles": 188,
|
"triangles": 188,
|
||||||
"vertices": 212
|
"vertices": 209
|
||||||
},
|
},
|
||||||
"stanchion": {
|
"stanchion": {
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
@@ -1097,13 +1097,13 @@
|
|||||||
"attach": "head",
|
"attach": "head",
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
"max": [
|
"max": [
|
||||||
0.141,
|
0.101,
|
||||||
0.065,
|
0.059,
|
||||||
0.158
|
0.158
|
||||||
],
|
],
|
||||||
"min": [
|
"min": [
|
||||||
-0.141,
|
-0.101,
|
||||||
0.005,
|
0.011,
|
||||||
0.003
|
0.003
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
@@ -41,6 +41,7 @@ flatpak run org.blender.Blender --background \
|
|||||||
--python "$PWD/tools/blender_assets.py" -- render
|
--python "$PWD/tools/blender_assets.py" -- render
|
||||||
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
|
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
|
||||||
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
|
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
|
||||||
|
python3 tools/blender_assets.py fit # head/clothing fit + bag-body clearance, per character
|
||||||
python3 tools/blender_assets.py list
|
python3 tools/blender_assets.py list
|
||||||
godot --headless --path . --import # after any rebuild
|
godot --headless --path . --import # after any rebuild
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"run": "docs/screens/realism/2026-11-01-gear-judge",
|
||||||
|
"judge_model": "google/gemini-2.5-flash-lite",
|
||||||
|
"sets": {
|
||||||
|
"baseline": {
|
||||||
|
"npc_portrait": {
|
||||||
|
"target": "docs/screens/concepts/npc_portrait_day.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 24.42,
|
||||||
|
"fine_detail": 9.88,
|
||||||
|
"contrast": 43.77,
|
||||||
|
"saturation_mean": 90.21,
|
||||||
|
"colour_spread": 50.11
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 7.47,
|
||||||
|
"fine_detail": 3.47,
|
||||||
|
"contrast": 52.66,
|
||||||
|
"saturation_mean": 93.44,
|
||||||
|
"colour_spread": 54.88
|
||||||
|
},
|
||||||
|
"metric_gap": 1.093,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"backdrop": 4.0,
|
||||||
|
"lighting": 3.0,
|
||||||
|
"overall": 2.0,
|
||||||
|
"materials": 2.0,
|
||||||
|
"texture_detail": 1.0,
|
||||||
|
"characters": 1.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"backdrop": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
1.0,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
"characters": [
|
||||||
|
1.0,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"fixes": [
|
||||||
|
"Improve character model realism with more detailed facial features, realistic skin shaders, and better clothing textures.",
|
||||||
|
"Enhance material properties for all objects, especially the brick building and pavement, to better simulate real-world reflectivity, roughness, and subsurface scattering.",
|
||||||
|
"Increase texture resolution and detail for all assets, particularly for the brickwork, pavement, and character clothing, to reduce the appearance of tiling and blurriness.",
|
||||||
|
"Refine lighting to match the warm, directional sunlight and soft shadows of the target image, paying attention to ambient occlusion and global illumination.",
|
||||||
|
"Add atmospheric perspective and volumetric fog to the backdrop to create a sense of depth and distance, mimicking the haze seen in the target image."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"candidate": {
|
||||||
|
"npc_portrait": {
|
||||||
|
"target": "docs/screens/concepts/npc_portrait_day.png",
|
||||||
|
"metrics": {
|
||||||
|
"edge_density": 24.43,
|
||||||
|
"fine_detail": 9.89,
|
||||||
|
"contrast": 43.74,
|
||||||
|
"saturation_mean": 90.17,
|
||||||
|
"colour_spread": 50.08
|
||||||
|
},
|
||||||
|
"target_metrics": {
|
||||||
|
"edge_density": 7.47,
|
||||||
|
"fine_detail": 3.47,
|
||||||
|
"contrast": 52.66,
|
||||||
|
"saturation_mean": 93.44,
|
||||||
|
"colour_spread": 54.88
|
||||||
|
},
|
||||||
|
"metric_gap": 1.094,
|
||||||
|
"judge": {
|
||||||
|
"scores": {
|
||||||
|
"backdrop": 5.0,
|
||||||
|
"lighting": 4.0,
|
||||||
|
"overall": 3.0,
|
||||||
|
"materials": 3.0,
|
||||||
|
"texture_detail": 2.0,
|
||||||
|
"characters": 2.0
|
||||||
|
},
|
||||||
|
"score_range": {
|
||||||
|
"backdrop": [
|
||||||
|
5.0,
|
||||||
|
5.0
|
||||||
|
],
|
||||||
|
"lighting": [
|
||||||
|
4.0,
|
||||||
|
4.0
|
||||||
|
],
|
||||||
|
"overall": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"materials": [
|
||||||
|
3.0,
|
||||||
|
3.0
|
||||||
|
],
|
||||||
|
"texture_detail": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
],
|
||||||
|
"characters": [
|
||||||
|
2.0,
|
||||||
|
2.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"runs": 1,
|
||||||
|
"fixes": [
|
||||||
|
"Improve character model realism with more detailed facial features, realistic skin shaders, and better hair simulation.",
|
||||||
|
"Enhance material properties for clothing, pavement, and buildings to include subsurface scattering, anisotropic reflections, and more varied roughness maps.",
|
||||||
|
"Increase texture resolution and add micro-detail (e.g., pores, fabric weave, concrete imperfections) to all surfaces.",
|
||||||
|
"Implement more sophisticated global illumination and atmospheric scattering for a softer, more realistic lighting and haze effect.",
|
||||||
|
"Refine the backdrop by adding more complex building geometry, varied foliage, and realistic distant city details with appropriate atmospheric perspective."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Gear fit report: `docs/screens/realism/2026-11-01-gear`
|
||||||
|
|
||||||
|
Influencer (`npc_coat`) portrait, forced with `--portrait-type=2`, before the gear-fit
|
||||||
|
changes (`da876e3`, with the capture yaw/focus fixes backported) and after them (`a478bfb`).
|
||||||
|
Both frames use the same camera, sun and portrait staging. The focus disc is hidden because
|
||||||
|
it is a gameplay overlay, not part of the character still.
|
||||||
|
|
||||||
|
| measurement | before | after |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| sunglasses width | 0.282 m (0.90× head) | **0.202 m** (0.64× head) |
|
||||||
|
| bag body inner face | 0.168 m (coat skirt 0.225, dress 0.260) | **0.262 m** (clears both) |
|
||||||
|
| firefighter helmet brim | sat on the brow ridge | **raised 2 cm** |
|
||||||
|
|
||||||
|
Capture commands:
|
||||||
|
|
||||||
|
```
|
||||||
|
xvfb-run -a godot --path . --script res://src/test/capture_realism.gd -- \
|
||||||
|
--stages=portrait --portrait-type=2 \
|
||||||
|
--out=res://docs/screens/realism/2026-11-01-gear/candidate-v2
|
||||||
|
tools/check_screenshots.sh # 11 good / 0 bad
|
||||||
|
python3 tools/scene_concepts.py realism \
|
||||||
|
--run docs/screens/realism/2026-11-01-gear --judge
|
||||||
|
```
|
||||||
|
|
||||||
|
The judge improved the portrait from **overall 2 / characters 1** (baseline) to
|
||||||
|
**overall 3 / characters 2** (candidate); the remaining fixes are the broader realism
|
||||||
|
targets (skin/cloth shaders, GI, backdrop haze), not gear fit.
|
||||||
|
|
||||||
|
Vision judge: `google/gemini-2.5-flash-lite` (scores 0-10, higher is more realistic compared with the concept target).
|
||||||
|
|
||||||
|
| set | shot | target | gap ↓ | edges | detail | contrast | overall | lighting | materials | texture | backdrop |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | npc_portrait | npc_portrait_day | 1.093 | 24.42 | 9.88 | 43.77 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 |
|
||||||
|
| candidate | npc_portrait | npc_portrait_day | 1.094 | 24.43 | 9.89 | 43.74 | 3.0 | 4.0 | 3.0 | 2.0 | 5.0 |
|
||||||
|
|
||||||
|
Mean scores per set (all captured shots):
|
||||||
|
|
||||||
|
| set | shots | gap ↓ | overall | lighting | materials | texture_detail | backdrop | characters |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| baseline | 1 | 1.093 | 2.0 | 3.0 | 2.0 | 1.0 | 4.0 | 1.0 |
|
||||||
|
| candidate | 1 | 1.094 | 3.0 | 4.0 | 3.0 | 2.0 | 5.0 | 2.0 |
|
||||||
|
|
||||||
|
**baseline / npc_portrait** fixes:
|
||||||
|
- Improve character model realism with more detailed facial features, realistic skin shaders, and better clothing textures.
|
||||||
|
- Enhance material properties for all objects, especially the brick building and pavement, to better simulate real-world reflectivity, roughness, and subsurface scattering.
|
||||||
|
- Increase texture resolution and detail for all assets, particularly for the brickwork, pavement, and character clothing, to reduce the appearance of tiling and blurriness.
|
||||||
|
- Refine lighting to match the warm, directional sunlight and soft shadows of the target image, paying attention to ambient occlusion and global illumination.
|
||||||
|
- Add atmospheric perspective and volumetric fog to the backdrop to create a sense of depth and distance, mimicking the haze seen in the target image.
|
||||||
|
|
||||||
|
**candidate / npc_portrait** fixes:
|
||||||
|
- Improve character model realism with more detailed facial features, realistic skin shaders, and better hair simulation.
|
||||||
|
- Enhance material properties for clothing, pavement, and buildings to include subsurface scattering, anisotropic reflections, and more varied roughness maps.
|
||||||
|
- Increase texture resolution and add micro-detail (e.g., pores, fabric weave, concrete imperfections) to all surfaces.
|
||||||
|
- Implement more sophisticated global illumination and atmospheric scattering for a softer, more realistic lighting and haze effect.
|
||||||
|
- Refine the backdrop by adding more complex building geometry, varied foliage, and realistic distant city details with appropriate atmospheric perspective.
|
||||||
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 856 KiB After Width: | Height: | Size: 856 KiB |
@@ -112,6 +112,8 @@ func _parse_args() -> void:
|
|||||||
elif text.begins_with("--stages="):
|
elif text.begins_with("--stages="):
|
||||||
for name in text.substr(9).split(","):
|
for name in text.substr(9).split(","):
|
||||||
_only_stages[name.strip_edges()] = true
|
_only_stages[name.strip_edges()] = true
|
||||||
|
elif text.begins_with("--portrait-type="):
|
||||||
|
_portrait_type = int(text.substr(16))
|
||||||
|
|
||||||
func _pair(text: String, into: Dictionary) -> void:
|
func _pair(text: String, into: Dictionary) -> void:
|
||||||
var cut := text.find("=", 6) # skip the "res://" scheme
|
var cut := text.find("=", 6) # skip the "res://" scheme
|
||||||
@@ -264,8 +266,16 @@ func _stage_portrait(delta: float, angle_deg: float = 0.0) -> bool:
|
|||||||
look.y = 0.0
|
look.y = 0.0
|
||||||
gm.guard.rotation.y = atan2(-look.x, -look.z)
|
gm.guard.rotation.y = atan2(-look.x, -look.z)
|
||||||
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
gm.guard.head.rotation.x = deg_to_rad(1.0)
|
||||||
# Turn the drawn rig (not the root) so the subject looks at the camera.
|
# The focus disc is a gameplay overlay, not part of the character still.
|
||||||
best.rotation.y = atan2(look.x, look.z)
|
for focus in best.find_children("FocusCircle", "MeshInstance3D", true, false):
|
||||||
|
(focus as MeshInstance3D).visible = false
|
||||||
|
# Face the camera: from the subject, the guard lies in the -look direction.
|
||||||
|
# Use the drawn rig so an FSM yaw on the root cannot fight the pose.
|
||||||
|
var rig := best.find_child("Rig", true, false) as Node3D
|
||||||
|
if rig != null:
|
||||||
|
rig.rotation.y = atan2(-look.x, -look.z)
|
||||||
|
else:
|
||||||
|
best.rotation.y = atan2(-look.x, -look.z)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@@ -308,7 +318,12 @@ func _faces_covered(npc: Node3D) -> bool:
|
|||||||
var profile = brain.get("profile")
|
var profile = brain.get("profile")
|
||||||
if profile == null:
|
if profile == null:
|
||||||
return false
|
return false
|
||||||
return int(profile.get("npc_type")) in HOODED_TYPES
|
var npc_type := int(profile.get("npc_type"))
|
||||||
|
# An explicit --portrait-type is a deliberate request to inspect headgear,
|
||||||
|
# including a hood; the default crowd selection still avoids covered faces.
|
||||||
|
if _portrait_type >= 0 and npc_type == _portrait_type:
|
||||||
|
return false
|
||||||
|
return npc_type in HOODED_TYPES
|
||||||
|
|
||||||
## Apply swaps, tints and normals to every material whose original albedo path
|
## Apply swaps, tints and normals to every material whose original albedo path
|
||||||
## matches. Called once per stage; materials are visited through the tree.
|
## matches. Called once per stage; materials are visited through the tree.
|
||||||
|
|||||||
@@ -191,6 +191,68 @@ func test_library_gear_attaches_to_the_contract_points() -> bool:
|
|||||||
responder.free()
|
responder.free()
|
||||||
return a and b and c and d
|
return a and b and c and d
|
||||||
|
|
||||||
|
## Horizontal bounds of a gear GLB in its attach-node space. The head/back
|
||||||
|
## contract is measured against the Blender fit report (head width 0.315 m,
|
||||||
|
## widest body silhouette 0.26 m); this catches a gear shrinking back inside
|
||||||
|
## the body or a cap sinking below the brow.
|
||||||
|
func _gear_bounds(node: Node3D) -> AABB:
|
||||||
|
var box := AABB()
|
||||||
|
var first := true
|
||||||
|
for child in node.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
var mi := child as MeshInstance3D
|
||||||
|
var b: AABB = mi.get_aabb()
|
||||||
|
# Gear meshes have no extra transform below the attach node.
|
||||||
|
if first:
|
||||||
|
box = b
|
||||||
|
first = false
|
||||||
|
else:
|
||||||
|
box = box.merge(b)
|
||||||
|
return box
|
||||||
|
|
||||||
|
func test_head_and_back_gear_clear_the_body_silhouette() -> bool:
|
||||||
|
const HEAD_WIDTH := 0.315
|
||||||
|
const WIDEST_BODY_RADIUS := 0.26
|
||||||
|
var problems: Array[String] = []
|
||||||
|
var seen := {}
|
||||||
|
for npc_type in [Rules.NpcType.TOURIST, Rules.NpcType.INFLUENCER,
|
||||||
|
Rules.NpcType.RESPONDER, Rules.NpcType.PERFORMER,
|
||||||
|
Rules.NpcType.DELIVERY, Rules.NpcType.TRESPASSER]:
|
||||||
|
var avatar := _avatar(npc_type, 5)
|
||||||
|
for child in avatar.find_children("Gear_*", "Node3D", true, false):
|
||||||
|
var gear := child as Node3D
|
||||||
|
var name := gear.name.trim_prefix("Gear_")
|
||||||
|
var attach := str(NpcAvatar.GEAR_ATTACH.get(name, ""))
|
||||||
|
if attach != "head" and attach != "back":
|
||||||
|
continue
|
||||||
|
seen[attach + ":" + name] = true
|
||||||
|
var box := _gear_bounds(gear)
|
||||||
|
if attach == "head":
|
||||||
|
var parent_name := gear.get_parent().name
|
||||||
|
if parent_name != "Head":
|
||||||
|
problems.append("%s is not under Head" % name)
|
||||||
|
if box.size.x > HEAD_WIDTH * 1.70:
|
||||||
|
problems.append("%s is %.3f m wide (head %.3f)" % [
|
||||||
|
name, box.size.x, HEAD_WIDTH])
|
||||||
|
# The hood wraps the neck; all other headgear must start on the skull.
|
||||||
|
if name != "hood" and box.position.y < 0.0:
|
||||||
|
problems.append("%s starts %.3f m below the head origin" % [
|
||||||
|
name, box.position.y])
|
||||||
|
else:
|
||||||
|
if gear.get_parent().name != "Rig":
|
||||||
|
problems.append("%s is not under Rig" % name)
|
||||||
|
var outer_x := maxf(absf(box.position.x), absf(box.end.x))
|
||||||
|
var outer_z := maxf(absf(box.position.z), absf(box.end.z))
|
||||||
|
if maxf(outer_x, outer_z) < WIDEST_BODY_RADIUS:
|
||||||
|
problems.append("%s only reaches %.3f m (body %.3f)" % [
|
||||||
|
name, maxf(outer_x, outer_z), WIDEST_BODY_RADIUS])
|
||||||
|
avatar.free()
|
||||||
|
for required in ["head:sunglasses", "head:firefighter_helmet", "head:cap",
|
||||||
|
"back:shoulder_bag", "back:backpack", "back:scba_tank"]:
|
||||||
|
if not seen.has(required):
|
||||||
|
problems.append("missing %s" % required)
|
||||||
|
return TestUtils.check(problems.is_empty(),
|
||||||
|
"head/back gear clears the body silhouette (%s)" % str(problems))
|
||||||
|
|
||||||
func test_fixed_detail_materials_are_not_tinted() -> bool:
|
func test_fixed_detail_materials_are_not_tinted() -> bool:
|
||||||
var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
|
var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
|
||||||
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ integration: nothing in the game loads them yet (see
|
|||||||
--python "$PWD/tools/blender_assets.py" -- render
|
--python "$PWD/tools/blender_assets.py" -- render
|
||||||
# outside Blender (stdlib + Pillow)
|
# outside Blender (stdlib + Pillow)
|
||||||
python3 tools/blender_assets.py audit # GLB contract checks
|
python3 tools/blender_assets.py audit # GLB contract checks
|
||||||
|
python3 tools/blender_assets.py fit # gear-vs-body fit measurements
|
||||||
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
|
python3 tools/blender_assets.py sheet # sheet_<category>.jpg (committed)
|
||||||
python3 tools/blender_assets.py list
|
python3 tools/blender_assets.py list
|
||||||
|
|
||||||
@@ -104,6 +105,17 @@ GEAR_ATTACH = {
|
|||||||
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
|
# Same attach points in Blender coordinates (Godot (x, y, z) -> Blender (x, -z, y)).
|
||||||
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
|
ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
|
||||||
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
|
"back": (0.0, 0.0, 1.10), "chest": (0.0, 0.0, 1.30)}
|
||||||
|
# Attach points in the authored GLB space (Godot coordinates), used by `fit`.
|
||||||
|
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
|
||||||
|
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
|
||||||
|
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
|
||||||
|
FIT_HEAD_MATERIALS = ("skin",)
|
||||||
|
FIT_CLOTHING_MATERIAL = "cloth"
|
||||||
|
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
|
||||||
|
FIT_HIP_BAND = (0.55, 0.95) # fallback body radius for trousers/vests
|
||||||
|
# Side bags include a strap that crosses the torso, so the full gear bbox cannot
|
||||||
|
# tell where the bag body starts. Measure the body-only y band for this fit.
|
||||||
|
FIT_BODY_BANDS = {"shoulder_bag": (-0.34, -0.16)}
|
||||||
GEAR = {
|
GEAR = {
|
||||||
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
|
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
|
||||||
"firefighter_helmet": "head",
|
"firefighter_helmet": "head",
|
||||||
@@ -961,12 +973,15 @@ def build_gear(name):
|
|||||||
elif name == "hood":
|
elif name == "hood":
|
||||||
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
|
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
|
||||||
elif name == "sunglasses":
|
elif name == "sunglasses":
|
||||||
|
# Keep the front frame narrow enough to sit inside the 0.315 m head
|
||||||
|
# silhouette. The arms/hinges set the overall width, so pull those in
|
||||||
|
# before shrinking the lenses; the lens plane stays in front of the eyes.
|
||||||
for s in (-1.0, 1.0):
|
for s in (-1.0, 1.0):
|
||||||
p.append(cyl("Lens", 0.03, 0.03, 0.008, (0.05 * s, -0.152, 0.035), M("lens"),
|
p.append(cyl("Lens", 0.024, 0.024, 0.008, (0.04 * s, -0.152, 0.035), M("lens"),
|
||||||
seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0)))
|
seg=14, rot=(math.radians(90), 0, 0), scale=(1.25, 1.0, 1.0)))
|
||||||
p.append(box("Hinge", (0.06, 0.008, 0.01), (0.11 * s, -0.150, 0.045),
|
p.append(box("Hinge", (0.035, 0.008, 0.01), (0.075 * s, -0.150, 0.045),
|
||||||
M("plastic_black")))
|
M("plastic_black")))
|
||||||
p.append(box("Arm", (0.006, 0.15, 0.008), (0.138 * s, -0.078, 0.042),
|
p.append(box("Arm", (0.006, 0.15, 0.008), (0.098 * s, -0.078, 0.042),
|
||||||
M("plastic_black")))
|
M("plastic_black")))
|
||||||
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
|
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
|
||||||
elif name == "firefighter_helmet":
|
elif name == "firefighter_helmet":
|
||||||
@@ -981,6 +996,10 @@ def build_gear(name):
|
|||||||
scale=(1.0, 1.08, 1.0)))
|
scale=(1.0, 1.08, 1.0)))
|
||||||
p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01))
|
p.append(box("Ridge", (0.03, 0.34, 0.05), (0, 0.0, 0.2), M("helmet"), bevel=0.01))
|
||||||
p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01))
|
p.append(box("Shield", (0.09, 0.012, 0.09), (0, -0.2, 0.11), M("brass"), bevel=0.01))
|
||||||
|
# The 2026-11-01 face pass added a brow ridge; lift the helmet 2 cm so
|
||||||
|
# the brim sits above the eyes instead of cutting into the brow.
|
||||||
|
for obj in p:
|
||||||
|
obj.data.transform(Matrix.Translation((0, 0, 0.02)))
|
||||||
# Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind.
|
# Back gear: origin = spine centre (Godot 0, 1.10, 0); +Y Blender = behind.
|
||||||
elif name == "backpack":
|
elif name == "backpack":
|
||||||
p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"),
|
p.append(box("Pack", (0.30, 0.15, 0.40), (0, 0.20, 0.08), M("pack_nylon"),
|
||||||
@@ -993,11 +1012,13 @@ def build_gear(name):
|
|||||||
(0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)],
|
(0.11 * s, -0.13, 0.18), (0.12 * s, -0.12, -0.08)],
|
||||||
0.012, M("plastic_black"), seg=6))
|
0.012, M("plastic_black"), seg=6))
|
||||||
elif name == "shoulder_bag":
|
elif name == "shoulder_bag":
|
||||||
p.append(box("Bag", (0.08, 0.26, 0.20), (0.21, 0.0, -0.22), M("leather"),
|
# Keep the bag's inner face outside the widest skirt flare (dress = 0.26):
|
||||||
|
# 0.292 - 0.06/2 = 0.262. The strap starts/ends on that same outboard line.
|
||||||
|
p.append(box("Bag", (0.06, 0.26, 0.20), (0.292, 0.0, -0.22), M("leather"),
|
||||||
bevel=0.025, segs=2))
|
bevel=0.025, segs=2))
|
||||||
p.append(tube("Strap", [(0.20, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26),
|
p.append(tube("Strap", [(0.262, 0.0, -0.12), (0.15, -0.12, 0.10), (0.0, -0.13, 0.26),
|
||||||
(-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05),
|
(-0.15, -0.05, 0.34), (-0.16, 0.10, 0.28), (0.0, 0.14, 0.05),
|
||||||
(0.20, 0.03, -0.12)], 0.009, M("leather"), seg=6))
|
(0.262, 0.03, -0.12)], 0.009, M("leather"), seg=6))
|
||||||
elif name == "scba_tank":
|
elif name == "scba_tank":
|
||||||
p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18))
|
p.append(cyl("Tank", 0.085, 0.085, 0.56, (0, 0.21, 0.02), M("steel"), seg=18))
|
||||||
p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8,
|
p.append(sphere("TopCap", 0.085, (0, 0.21, 0.30), M("steel"), seg=18, rings=8,
|
||||||
@@ -1672,6 +1693,143 @@ def audit_asset(category, name):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
def character_fit_reference(name):
|
||||||
|
"""Measure a character's head width and hip/skirt radius from its GLB.
|
||||||
|
|
||||||
|
Head width uses skin/hair vertices whose strongest joint is Head inside the
|
||||||
|
fixed head band. Clothing radius uses the cloth vertices in the hip/skirt
|
||||||
|
band. Both are reported in metres and rounded to millimetres.
|
||||||
|
"""
|
||||||
|
path = asset_path("npc", name)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||||||
|
except (struct.error, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
nodes = gltf.get("nodes", [])
|
||||||
|
joints = [nodes[j].get("name", "") for j in gltf.get("skins", [{}])[0].get("joints", [])]
|
||||||
|
materials = [m.get("name", "") for m in gltf.get("materials", [])]
|
||||||
|
head_x: list[float] = []
|
||||||
|
head_y: list[float] = []
|
||||||
|
cloth_x: list[float] = []
|
||||||
|
hip_x: list[float] = []
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for prim in mesh.get("primitives", []):
|
||||||
|
attrs = prim.get("attributes", {})
|
||||||
|
if "POSITION" not in attrs or "JOINTS_0" not in attrs or "WEIGHTS_0" not in attrs:
|
||||||
|
continue
|
||||||
|
mat_index = prim.get("material")
|
||||||
|
mat_name = materials[mat_index] if mat_index is not None and mat_index < len(materials) else ""
|
||||||
|
positions = _accessor(gltf, binary, attrs["POSITION"])
|
||||||
|
joint_rows = _accessor(gltf, binary, attrs["JOINTS_0"])
|
||||||
|
weight_rows = _accessor(gltf, binary, attrs["WEIGHTS_0"])
|
||||||
|
for pos, joint_row, weight_row in zip(positions, joint_rows, weight_rows):
|
||||||
|
strongest = max(range(4), key=lambda i: weight_row[i])
|
||||||
|
bone = joints[joint_row[strongest]] if joint_row[strongest] < len(joints) else ""
|
||||||
|
if (mat_name in FIT_HEAD_MATERIALS and bone == "Head"
|
||||||
|
and weight_row[strongest] > 0.5 and HEAD_BAND[0] <= pos[1] <= HEAD_BAND[1]):
|
||||||
|
head_x.append(pos[0])
|
||||||
|
head_y.append(pos[1])
|
||||||
|
if (mat_name == FIT_CLOTHING_MATERIAL
|
||||||
|
and FIT_CLOTHING_BAND[0] <= pos[1] <= FIT_CLOTHING_BAND[1]):
|
||||||
|
cloth_x.append(pos[0])
|
||||||
|
if (mat_name == "pants"
|
||||||
|
and FIT_HIP_BAND[0] <= pos[1] <= FIT_HIP_BAND[1]):
|
||||||
|
hip_x.append(pos[0])
|
||||||
|
skirt_radius = max(abs(x) for x in cloth_x) if cloth_x else 0.0
|
||||||
|
hip_radius = max(abs(x) for x in hip_x) if hip_x else 0.0
|
||||||
|
clothing_radius = max(skirt_radius, hip_radius)
|
||||||
|
return {
|
||||||
|
"head_width": round(max(head_x) - min(head_x), 3) if head_x else 0.0,
|
||||||
|
"head_y_min": round(min(head_y), 3) if head_y else 0.0,
|
||||||
|
"head_y_max": round(max(head_y), 3) if head_y else 0.0,
|
||||||
|
"clothing_radius": round(clothing_radius, 3),
|
||||||
|
"clothing_source": "skirt" if skirt_radius >= hip_radius and skirt_radius > 0 else "hip",
|
||||||
|
"head_band": HEAD_BAND,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gear_local_positions(name):
|
||||||
|
path = asset_path("npc_gear", name)
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
_magic, _version, _length, _size, gltf, binary = _read_glb(path)
|
||||||
|
except (struct.error, ValueError, KeyError):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for prim in mesh.get("primitives", []):
|
||||||
|
attrs = prim.get("attributes", {})
|
||||||
|
if "POSITION" in attrs:
|
||||||
|
out.extend(_accessor(gltf, binary, attrs["POSITION"]))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _gear_fit(name, reference):
|
||||||
|
attach = GEAR.get(name)
|
||||||
|
if attach not in ("head", "back", "chest"):
|
||||||
|
return None
|
||||||
|
audit = audit_asset("npc_gear", name)
|
||||||
|
bounds = audit.get("bounds")
|
||||||
|
if not bounds:
|
||||||
|
return None
|
||||||
|
offset = ATTACH_GODOT[attach]
|
||||||
|
lo = [bounds["min"][i] + offset[i] for i in range(3)]
|
||||||
|
hi = [bounds["max"][i] + offset[i] for i in range(3)]
|
||||||
|
result = {
|
||||||
|
"name": name,
|
||||||
|
"attach": attach,
|
||||||
|
"min": [round(v, 3) for v in lo],
|
||||||
|
"max": [round(v, 3) for v in hi],
|
||||||
|
}
|
||||||
|
if attach == "head":
|
||||||
|
result.update({
|
||||||
|
"width": round(hi[0] - lo[0], 3),
|
||||||
|
"width_ratio": round((hi[0] - lo[0]) / reference["head_width"], 3)
|
||||||
|
if reference["head_width"] else 0.0,
|
||||||
|
"inside_band": lo[1] >= HEAD_BAND[0] - 0.005 and hi[1] <= HEAD_BAND[1] + 0.005,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Compare the widest point (|x|) and the corner radial extent with the
|
||||||
|
# skirt flare. The radial value catches bags that sit diagonally out.
|
||||||
|
inner_x = min(abs(lo[0]), abs(hi[0]))
|
||||||
|
if name in FIT_BODY_BANDS:
|
||||||
|
band = FIT_BODY_BANDS[name]
|
||||||
|
body_x = [abs(p[0]) for p in _gear_local_positions(name)
|
||||||
|
if band[0] <= p[1] <= band[1]]
|
||||||
|
if body_x:
|
||||||
|
inner_x = min(body_x)
|
||||||
|
x_extent = max(abs(lo[0]), abs(hi[0]))
|
||||||
|
radial = max(math.hypot(x, z) for x in (lo[0], hi[0]) for z in (lo[2], hi[2]))
|
||||||
|
result.update({
|
||||||
|
"inner_x": round(inner_x, 3),
|
||||||
|
"x_extent": round(x_extent, 3),
|
||||||
|
"radial": round(radial, 3),
|
||||||
|
# Positive clearance means the bag's inner face is outside the body.
|
||||||
|
"x_clearance": round(inner_x - reference["clothing_radius"], 3),
|
||||||
|
"radial_clearance": round(reference["clothing_radius"] - radial, 3),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fit_report():
|
||||||
|
"""Return per-character gear fit measurements for the `fit` command/tests."""
|
||||||
|
report = []
|
||||||
|
for character, spec in CHARACTERS.items():
|
||||||
|
reference = character_fit_reference(character)
|
||||||
|
if reference is None:
|
||||||
|
continue
|
||||||
|
rows = []
|
||||||
|
for gear_name in spec.get("gear", []):
|
||||||
|
row = _gear_fit(gear_name, reference)
|
||||||
|
if row:
|
||||||
|
rows.append(row)
|
||||||
|
report.append({"character": character, "reference": reference, "gear": rows})
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
def write_manifest(results):
|
def write_manifest(results):
|
||||||
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
|
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
|
||||||
"assets": {}}
|
"assets": {}}
|
||||||
@@ -1735,6 +1893,8 @@ def main(argv=None):
|
|||||||
s.add_argument("--only", default="")
|
s.add_argument("--only", default="")
|
||||||
a = sub.add_parser("audit")
|
a = sub.add_parser("audit")
|
||||||
a.add_argument("--json", action="store_true")
|
a.add_argument("--json", action="store_true")
|
||||||
|
fit = sub.add_parser("fit")
|
||||||
|
fit.add_argument("--json", action="store_true")
|
||||||
sub.add_parser("sheet")
|
sub.add_parser("sheet")
|
||||||
sub.add_parser("list")
|
sub.add_parser("list")
|
||||||
args = ap.parse_args(raw)
|
args = ap.parse_args(raw)
|
||||||
@@ -1766,6 +1926,36 @@ def main(argv=None):
|
|||||||
if args.cmd == "sheet":
|
if args.cmd == "sheet":
|
||||||
contact_sheets()
|
contact_sheets()
|
||||||
return 0
|
return 0
|
||||||
|
if args.cmd == "fit":
|
||||||
|
report = fit_report()
|
||||||
|
if not report:
|
||||||
|
print("no built character assets; run build first", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(report, indent=2))
|
||||||
|
else:
|
||||||
|
for entry in report:
|
||||||
|
ref = entry["reference"]
|
||||||
|
print(f"{entry['character']:16s} head_width={ref['head_width']:.3f} m "
|
||||||
|
f"clothing_radius={ref['clothing_radius']:.3f} m "
|
||||||
|
f"({ref['clothing_source']})")
|
||||||
|
for row in entry["gear"]:
|
||||||
|
lo, hi = row["min"], row["max"]
|
||||||
|
box = (f"x=[{lo[0]:+.3f},{hi[0]:+.3f}] "
|
||||||
|
f"y=[{lo[1]:+.3f},{hi[1]:+.3f}] "
|
||||||
|
f"z=[{lo[2]:+.3f},{hi[2]:+.3f}]")
|
||||||
|
if row["attach"] == "head":
|
||||||
|
flag = "inside" if row["inside_band"] else "OUTSIDE"
|
||||||
|
print(f" {row['name']:20s} head {box} "
|
||||||
|
f"width={row['width']:.3f} ({row['width_ratio']:.2f}x head) "
|
||||||
|
f"band={flag}")
|
||||||
|
else:
|
||||||
|
clip = "CLIP" if row["x_clearance"] < 0 else "clear"
|
||||||
|
print(f" {row['name']:20s} {row['attach']:5s} {box} "
|
||||||
|
f"inner_x={row['inner_x']:.3f} "
|
||||||
|
f"clearance x={row['x_clearance']:+.3f} "
|
||||||
|
f"r={row['radial_clearance']:+.3f} {clip}")
|
||||||
|
return 0
|
||||||
results = [audit_asset(c, n) for c, n in catalogue()]
|
results = [audit_asset(c, n) for c, n in catalogue()]
|
||||||
if args.json:
|
if args.json:
|
||||||
print(json.dumps(results, indent=2))
|
print(json.dumps(results, indent=2))
|
||||||
|
|||||||
@@ -45,6 +45,31 @@ class BuiltAssetTests(unittest.TestCase):
|
|||||||
bad[name] = result["issues"]
|
bad[name] = result["issues"]
|
||||||
self.assertEqual(bad, {})
|
self.assertEqual(bad, {})
|
||||||
|
|
||||||
|
def test_fit_report_measures_gear_against_body(self):
|
||||||
|
report = {entry["character"]: entry for entry in ba.fit_report()}
|
||||||
|
self.assertIn("npc_coat", report)
|
||||||
|
coat = report["npc_coat"]
|
||||||
|
self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2)
|
||||||
|
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3)
|
||||||
|
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
|
||||||
|
self.assertEqual(sunglasses["attach"], "head")
|
||||||
|
self.assertLessEqual(sunglasses["width"],
|
||||||
|
0.70 * coat["reference"]["head_width"])
|
||||||
|
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
|
||||||
|
self.assertEqual(bag["attach"], "back")
|
||||||
|
self.assertGreaterEqual(bag["x_extent"], 0.2)
|
||||||
|
self.assertGreater(bag["x_clearance"], 0)
|
||||||
|
self.assertGreater(bag["inner_x"], coat["reference"]["clothing_radius"])
|
||||||
|
for character in report.values():
|
||||||
|
for row in character["gear"]:
|
||||||
|
self.assertEqual(len(row["min"]), 3)
|
||||||
|
self.assertEqual(len(row["max"]), 3)
|
||||||
|
self.assertIn("npc_dress", report)
|
||||||
|
dress = report["npc_dress"]
|
||||||
|
self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.26, places=3)
|
||||||
|
dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag")
|
||||||
|
self.assertGreater(dress_bag["x_clearance"], 0)
|
||||||
|
|
||||||
def test_manifest_covers_catalogue(self):
|
def test_manifest_covers_catalogue(self):
|
||||||
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
|
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
|
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})
|
||||||
|
|||||||