Compare commits

...
8 Commits
Author SHA1 Message Date
zwitschi 26d64baa01 Close the NPC gear-fit section
Full build / Import + headless tests (push) Successful in 34s
Full build / Full desktop build + optional release (push) Skipped
Move the completed gear section to DONE.md and record the hooded-portrait
follow-up. An explicit --portrait-type now overrides the hooded-subject skip,
though the day roster has no types 5-7 yet. Headless 229/229.
2026-11-01 18:12:37 +01:00
zwitschi 151630e957 Recapture the gear portrait and gallery shots
Fix the portrait staging: face the rig toward the camera (the old yaw was 180
degrees out) and hide the focus disc, which was covering the face in close-ups.
Add the before/after Influencer portrait, fit measurements and judge scores in
docs/screens/realism/2026-11-01-gear. Re-run check_screenshots (11 good/0 bad);
headless 229/229.
2026-11-01 18:09:50 +01:00
zwitschi 1b319c48b7 Guard gear fit with headless and Blender regression checks
The Python fit test now caps the glasses at 0.70x head width and requires a
positive bag clearance on both the coat and dress. The Godot character suite
loads head/back gear on six roles and checks attach parents, head width and
brow bounds, and that back gear reaches beyond the 0.26 m body silhouette.
Rebuilt and re-baked all 18 gear assets; no-op GLB export churn was reverted.
Headless 229/229, Python 87/87.
2026-11-01 18:04:51 +01:00
zwitschi a478bfbf35 Clear the firefighter helmet brim from the brow
The face pass added a brow ridge; lift the helmet mesh 2 cm so the brim sits
above the eyes. Cap and hood were rendered on their characters and leave the
brow/ears and face readable. Rebuild and re-bake the helmet; Python 87/87.
2026-11-01 18:02:38 +01:00
zwitschi 702f54ae53 Move the shoulder bag clear of coat and dress skirts
Narrow the bag to 0.06 m and move it outboard to centre x=0.292. The body inner
face is now 0.262 m, clearing the coat skirt (0.225) and dress (0.260); the
strap lower points start on the same line. The fit report measures the bag body
separately from the torso strap and uses a signed inner-face clearance. Run
Blender build/bake and Godot import. Python tooling 87/87.
2026-11-01 17:58:47 +01:00
zwitschi bbf4693475 Narrow the sunglasses and fix portrait-type parsing
Move the hinges to +/-0.075 (0.035 box), arms to +/-0.098, and lenses to a
0.024 m radius while keeping the lens plane in front of the eyes. Rebuild the
GLB and AO; fit now measures 0.202 m total width (0.64x head). capture_realism
now parses the documented --portrait-type option instead of ignoring it.
Python tooling 87/87; headless 228/228.
2026-11-01 17:56:10 +01:00
zwitschi d7b0203502 Document commit and milestone steps in the agent workflow 2026-11-01 17:53:24 +01:00
zwitschi da876e3545 Add a gear-vs-body fit report to the Blender asset tool
`blender_assets.py fit` measures each character head width (skin vertices
weighted to Head in y 1.55-1.81) and clothing radius (cloth skirt in y 0.30-0.52,
pants hip fallback), then prints every attached head/back/chest gear bounding box
with head-band status and x/radial skirt clearance. The report confirms the
glasses width and shoulder-bag coat clip, giving the next fit fixes numbers.
Python tooling 87/87; asset audit clean.
2026-11-01 17:52:38 +01:00
25 changed files with 570 additions and 50 deletions

No files matched your search

+4
View File
@@ -145,6 +145,10 @@ For each task:
5. Update documentation and `MEMORY.md` if the change creates lasting context.
6. Mark the task 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.
+46
View File
@@ -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.
- [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.
+19
View File
@@ -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.
- `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.
- `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
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
@@ -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()`.
`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).
**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
(`<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
+2 -33
View File
@@ -3,39 +3,6 @@
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.
## 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)
Today all three venues share the Hollywood `WorldBuilder` geometry, so the Joke Shop and studio
@@ -86,6 +53,8 @@ path.
## 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,
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.
+8 -8
View File
@@ -287,12 +287,12 @@
"bounds_godot": {
"max": [
0.26,
0.225,
0.245,
0.26
],
"min": [
-0.26,
0.015,
0.035,
-0.351
]
},
@@ -1001,7 +1001,7 @@
"attach": "back",
"bounds_godot": {
"max": [
0.25,
0.322,
0.348,
0.139
],
@@ -1017,7 +1017,7 @@
],
"path": "assets/models/blender/npc_gear/shoulder_bag.glb",
"triangles": 188,
"vertices": 212
"vertices": 209
},
"stanchion": {
"bounds_godot": {
@@ -1097,13 +1097,13 @@
"attach": "head",
"bounds_godot": {
"max": [
0.141,
0.065,
0.101,
0.059,
0.158
],
"min": [
-0.141,
0.005,
-0.101,
0.011,
0.003
]
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

+1
View File
@@ -41,6 +41,7 @@ flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- render
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 fit # head/clothing fit + bag-body clearance, per character
python3 tools/blender_assets.py list
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 856 KiB

After

Width:  |  Height:  |  Size: 856 KiB

+18 -3
View File
@@ -112,6 +112,8 @@ func _parse_args() -> void:
elif text.begins_with("--stages="):
for name in text.substr(9).split(","):
_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:
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
gm.guard.rotation.y = atan2(-look.x, -look.z)
gm.guard.head.rotation.x = deg_to_rad(1.0)
# Turn the drawn rig (not the root) so the subject looks at the camera.
best.rotation.y = atan2(look.x, look.z)
# The focus disc is a gameplay overlay, not part of the character still.
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
@@ -308,7 +318,12 @@ func _faces_covered(npc: Node3D) -> bool:
var profile = brain.get("profile")
if profile == null:
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
## matches. Called once per stage; materials are visited through the tree.
+62
View File
@@ -191,6 +191,68 @@ func test_library_gear_attaches_to_the_contract_points() -> bool:
responder.free()
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:
var avatar := _avatar(Rules.NpcType.RESPONDER, 3)
var mi := avatar.find_child("npc_base", true, false) as MeshInstance3D
+196 -6
View File
@@ -14,6 +14,7 @@ integration: nothing in the game loads them yet (see
--python "$PWD/tools/blender_assets.py" -- render
# outside Blender (stdlib + Pillow)
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 list
@@ -104,6 +105,17 @@ GEAR_ATTACH = {
# 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),
"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 = {
"cap": "head", "beanie": "head", "hood": "head", "sunglasses": "head",
"firefighter_helmet": "head",
@@ -961,12 +973,15 @@ def build_gear(name):
elif name == "hood":
p.append(_hood("Hood", M("plastic_grey"), (0, 0, 0.015)))
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):
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)))
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")))
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")))
p.append(box("Bridge", (0.04, 0.006, 0.008), (0, -0.155, 0.045), M("plastic_black")))
elif name == "firefighter_helmet":
@@ -981,6 +996,10 @@ def build_gear(name):
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("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.
elif name == "backpack":
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.012, M("plastic_black"), seg=6))
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))
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.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":
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,
@@ -1672,6 +1693,143 @@ def audit_asset(category, name):
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):
out = {"generator": "tools/blender_assets.py", "attach_points": GEAR_ATTACH,
"assets": {}}
@@ -1735,6 +1893,8 @@ def main(argv=None):
s.add_argument("--only", default="")
a = sub.add_parser("audit")
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("list")
args = ap.parse_args(raw)
@@ -1766,6 +1926,36 @@ def main(argv=None):
if args.cmd == "sheet":
contact_sheets()
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()]
if args.json:
print(json.dumps(results, indent=2))
+25
View File
@@ -45,6 +45,31 @@ class BuiltAssetTests(unittest.TestCase):
bad[name] = result["issues"]
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):
manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8"))
self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()})