Phase 8 checkpoint

This commit is contained in:
2026-11-01 20:52:39 +03:00
parent b8af541c39
commit 8fbe9d1255
16 changed files with 1449 additions and 54 deletions
+6
View File
@@ -133,6 +133,8 @@ cmake --build build --target demoSokoban # Demo: forklift sokoban activity
cmake --build build --target demoTaxi # Demo: taxi driver (procedural town, sedans, NPC traffic, fare chain)
cmake --build build --target characters-all # All adult/baby/child/teenager/elder character assets
cmake --build build --target character-assets-check # Validate age catalogs/LODs/resource tree
# Optional: seed a detailed-nude source blend from the existing base parts
cmake --build build --target init-nude-male
```
### Parallel asset builds
@@ -395,6 +397,10 @@ JPH::BodyID body = physics->createBody(shape, 1.0f, position, rotation,
adult → age-band seed converter (rig scaling, mesh/shape-key
deformation, garment/hair conversion, animation retargeting; contract:
`assets/blender/characters/AGES-PLAN.md`)
- `characters/make_nude_blend.py` - seeds the optional
`nude-<stem>.blend` detailed-nude source files from the existing
layer-0 body parts (`init-nude-<stem>` targets; the build appends the
file after all clothing stages)
- `characters/validate_age_assets.py` - structural validation of the
generated age catalogs/LODs/resource tree
3. **Build**: CMake processes assets during build
+101 -43
View File
@@ -51,9 +51,13 @@ Pipeline per stem (`male`, `female`, `baby-male`, `child-female`, ...):
`process_clothes.py` fits them to the body (weight transfer from the
reference body parts, seam fixes), `combine_clothes.py` bakes the
combined meshes for garment combinations (see §3).
3. `consolidate.py` appends everything into
`edited-normal-<stem>-consolidated.blend` and leaves exactly one body
armature.
3. `consolidate.py` appends each clothing stage and leaves exactly one
body armature. The clothes-only result is
`edited-normal-<stem>-consolidated-clothes.blend`. If
`nude-<stem>.blend` exists, a final consolidation appends its
detailed-nude parts; otherwise the clothes-only blend is copied.
Either way the final output is
`edited-normal-<stem>-consolidated.blend`.
4. `export_models2.py` exports the consolidated blend through
blender2ogre: one `.mesh` per catalog mesh object, the shared
`.skeleton`, `.material` files, the prefixed texture atlases,
@@ -158,45 +162,71 @@ Resolution per slot (`resolveMesh`), given the record's
`combine_clothes.py` — a new garment only needs its own entry, the
combinations are generated.
- **outfitLevel 1 (underwear)**: the selected layer-1 mesh.
- **outfitLevel 0 (nude)**: the layer-0 entry tagged
**`detailed-nude`** when one exists for the slot (phase 6.C.1) —
the plain layer-0 meshes are simplified composition bases meant to
disappear under garments and lack anatomical detail. Author at most
one `detailed-nude` variant per slot per sex (`top`/`bottom`/`feet`);
slots without one keep the composition base, so the variants can be
added one slot at a time. Tag it via `clothing_tags =
"detailed-nude"` on a layer-0 mesh.
- **No layer-1/2 garment resolved (nude slot)**: the layer-0 entry
tagged **`detailed-nude`** when one exists for the slot (phase
6.C.1) — the plain layer-0 meshes are simplified composition bases
meant to disappear under garments and lack anatomical detail. This
applies to `outfitLevel 0` and to `outfitLevel 1/2` records whose
garment selections are empty or point at a missing catalog entry.
Slots without a detailed variant keep the composition base, so the
variants can be authored one slot at a time. Tag it via
`clothing_tags = "detailed-nude"` on a layer-0 mesh.
- Final fallback: the shortest-named layer-0 entry (Blender `.001`
duplicates are penalised), then any entry.
### Authoring a `detailed-nude` part (whole-body-part replacement)
### Detailed-nude source files (`nude-<stem>.blend`)
Detailed-nude parts live in an optional, separate source blend per
age/sex stem: `nude-<stem>.blend` (`nude-male.blend`,
`nude-baby-female.blend`, ...). The file can contain up to three
complete body parts, one per slot: `top`, `bottom`, `feet`. It is
optional and may be populated one slot at a time; slots without a part
fall back to the composition bases. The normal build appends the file
**after** all clothing stages, so `combine_clothes.py` never sees these
parts and cannot delete or combine them.
A detailed-nude mesh is a COMPLETE standalone body part that replaces
the simplified composition base at runtime when the character wears
nothing. It is NOT built from the base part by the pipeline and no
geometry is ever deleted from it:
the simplified composition base at runtime when the slot has no
clothes. It is NOT built from the base part by the pipeline and no
geometry is ever deleted from it. The plain base part must stay in
`edited-normal-<stem>.blend` — garments still need it as their
combination target.
- The hidden-geometry deletion (`combine_clothes.py`) only runs on
copies of the base meshes a garment explicitly names in its
`ref_part` property. No garment references your new mesh, so it
passes through the pipeline untouched, and the plain base part must
stay in the blend — garments still need it as their combination
target.
- The replacement happens at mesh-resolution time only: outfitLevel 0
shows the detailed part, outfitLevel >= 1 goes back to the
composition bases/combined meshes.
#### Seeding the file from the existing base parts
Steps:
The quickest start is the seeding helper, which copies the existing
layer-0 `BodyTop` / `BodyBottom` / `BodyFeet` parts into a new
`nude-<stem>.blend`, renames them `*_detailed`, strips clothing
metadata and tags them `detailed-nude`:
1. Model the part as a NEW mesh object in the base body blend
`edited-normal-<sex>.blend`, next to the existing base parts
(`BodyTop`/`BodyBottom`/...). One detailed variant per slot per
sex at most (`top`/`bottom`/`feet`); the engine takes the first
`detailed-nude` entry it finds for the slot.
2. Skin it to the SAME `mixamorig:` armature with the same bind pose,
max 4 bone influences per vertex. (`consolidate.py` re-parents it
to the armature named by its `sex` property and adds an Armature
modifier if missing, but the weights are your job.)
```bash
# Recommended: goes through the Blender lock.
cmake --build build-vscode --target init-nude-male # adult male
cmake --build build-vscode --target init-nude-child-female
```
Or run the helper directly (stop parallel asset builds first):
```bash
blender -b -Y -P assets/blender/characters/make_nude_blend.py -- \
--age adult --sex male
```
The helper refuses to overwrite an existing file unless `--force` is
passed. It includes the body armature for editing and skinning; the
pipeline keeps one body armature during consolidation. If you
regenerate an age seed with `--force`, its base boundaries can change;
re-seed or adjust the matching `nude-<stem>.blend` afterwards.
#### Editing requirements
1. Keep or replace the `BodyTop_detailed` / `BodyBottom_detailed` /
`BodyFeet_detailed` meshes. At most one `detailed-nude` entry per
slot per stem; the engine takes the first entry it finds.
2. Skin each part to the SAME `mixamorig:` armature with the same bind
pose, max 4 bone influences per vertex. (`consolidate.py`
re-parents it to the armature named by its `sex` property and adds
an Armature modifier if missing, but the weights are your job.)
3. Match the boundary loops of the base part it replaces (neck, waist,
wrists, ankles) vertex-for-vertex in position: the consolidate
step's seam fixer unifies shape-key offsets and normals for
@@ -205,17 +235,38 @@ Steps:
against the face/hands/neighbouring slots.
4. Copy the same shape keys (`fat`, ...) onto it — helper scripts in
this directory: `transfer_shape_keys.py`,
`propagate_shape_keys.py`, `add_missing_shape_keys.py`.
`propagate_shape_keys.py`, `add_missing_shape_keys.py`. The seeding
helper already does this from `Body_shapes`, and the final
consolidation runs `add_missing_shape_keys.py` as a safety net, so a
manually authored part also receives the missing keys automatically.
5. Custom properties on the mesh OBJECT: `age`/`sex`/`slot` as usual,
`layer` = 0, `clothing_tags` = `detailed-nude`, no `garments`.
6. Give object AND mesh data a unique name (e.g.
`male_BodyTop_detailed`); the mesh data name becomes the `.mesh`
file name.
7. Rebuild (`edited-blends` target or full build). No CMake or code
changes are needed: consolidate/export discover the part by its
custom properties and emit `body_part_<mesh data name>.json`
automatically, and the runtime starts using it for nude characters
of that slot.
The seeding helper sets these automatically.
6. Give object AND mesh data unique names (e.g. `BodyTop_detailed`);
the mesh data name becomes the exported `.mesh` name prefixed with
the stem (`male_BodyTop_detailed.mesh`).
7. Rebuild the character (`character-<stem>`, `characters-all`, or the
full build). No CMake edits are needed: `CMakeLists.txt` detects
`nude-<stem>.blend`, appends it after the clothing consolidation and
exports the parts as layer-0 catalog entries. If the file was added
while CMake was already configured, the `CONFIGURE_DEPENDS` glob
re-runs CMake on the next build (or run `cmake -B build-vscode -S .`
manually).
8. Verify with `cmake --build build-vscode --target
character-assets-check`: when a `nude-<stem>.blend` exists, the
validator requires at least one `detailed-nude` catalog entry, checks
that every entry is layer 0 with no `garments` and matching
age/sex, and warns about missing slots.
Do not add detailed-nude meshes to `edited-normal-<stem>.blend` for the
new workflow. The separate file is what keeps them out of the clothing
combination/removal path and lets them be replaced independently. Older
assets with detailed-nude meshes already in the base blend still resolve
at runtime; they simply do not get the separate-file workflow.
The current LOD2/3 whole-character impostors still use the default
dressed outfit. Detailed-nude parts are visible at LOD0/LOD1; a nude
impostor variant is future work.
The town population picks looks deterministically per NPC:
`fnv1a(npcKey|slot)` indexes into the layer-1 hair list and (when
@@ -356,3 +407,10 @@ contract suite:
./demoTaxi --headless --test-motion
./demoTaxi --headless --test-detail
```
Detailed-nude verification: seed a file with `init-nude-<stem>`, rebuild
that stem, then set a character of that age/sex to outfit level Nude (or
clear its layer-1/2 garment selections). The editor's "Resolved:" line
and the slot mesh should show the `*_detailed` catalog entry; clothed
characters must keep their selected garments. `--test-ages` also checks
the runtime preference for every stem that has a `detailed-nude` entry.
+94 -2
View File
@@ -28,6 +28,20 @@ function(character_source_blend AGE SEX OUT)
PARENT_SCOPE)
endfunction()
function(character_nude_source AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT}
"${CMAKE_CURRENT_SOURCE_DIR}/nude-${STEM}.blend"
PARENT_SCOPE)
endfunction()
# Optional detailed-nude sources: one nude-<stem>.blend per age/sex.
# CONFIGURE_DEPENDS makes CMake re-run when a file is added/removed, so the
# per-stem pipeline below can switch between the plain clothes-consolidated
# blend and the extra nude-append stage without a manual reconfigure.
file(GLOB CHARACTER_NUDE_BLENDS CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/nude-*.blend")
function(character_glb AGE SEX OUT)
character_stem(${AGE} ${SEX} STEM)
set(${OUT} "${CMAKE_BINARY_DIR}/characters/${STEM}/normal-${STEM}.glb"
@@ -471,6 +485,22 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
character_build_blend(${AGE} ${SEX} BASE_BUILD)
character_nude_source(${AGE} ${SEX} NUDE_SRC)
if(EXISTS "${NUDE_SRC}")
set(NUDE_STATE 1)
else()
set(NUDE_STATE 0)
endif()
# The state file is rewritten only when its content changes. It is
# a dependency of the final blend, so adding/removing a nude source
# forces the final stage to re-run even when its output path stays
# the same.
configure_file(
"${CMAKE_CURRENT_SOURCE_DIR}/cmake/nude_state.in"
"${CMAKE_CURRENT_BINARY_DIR}/nude-${STEM}.state"
@ONLY)
set(CLOTHES_CONSOLIDATED
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-consolidated-clothes.blend")
set(SHAPES_BLEND
"${CMAKE_CURRENT_BINARY_DIR}/edited-normal-${STEM}-shapes.blend")
set(CONSOLIDATED
@@ -490,7 +520,8 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
set(SLOT_OUTPUT
"edited-normal-${STEM}-consolidated-${SLOT}.blend")
if(SLOT STREQUAL LAST_SLOT)
set(SLOT_OUTPUT "edited-normal-${STEM}-consolidated.blend")
set(SLOT_OUTPUT
"edited-normal-${STEM}-consolidated-clothes.blend")
endif()
add_clothes_pipeline("${CMAKE_CURRENT_BINARY_DIR}/${SLOT_INPUT}"
"${WEIGHTED}"
@@ -498,6 +529,46 @@ foreach(AGE ${CHARACTER_BUILD_AGES})
set(SLOT_INPUT ${SLOT_OUTPUT})
endforeach()
# Final stage: the clothes pipeline always ends at
# *-consolidated-clothes.blend. When an optional
# nude-<stem>.blend exists, append its layer-0 detailed-nude parts
# after all clothing stages (so combine_clothes.py never sees or
# removes them); otherwise stage a plain copy as *-consolidated.blend.
set(NUDE_STATE_FILE
"${CMAKE_CURRENT_BINARY_DIR}/nude-${STEM}.state")
set(FINAL_DEPS "${CLOTHES_CONSOLIDATED}" "${NUDE_STATE_FILE}")
if(EXISTS "${NUDE_SRC}")
list(APPEND FINAL_DEPS "${NUDE_SRC}")
add_custom_command(
OUTPUT ${CONSOLIDATED}
COMMAND ${BLENDER_LOCKED} -b -Y "${CLOTHES_CONSOLIDATED}"
-P ${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py --
"${NUDE_SRC}"
"${CONSOLIDATED}"
COMMAND ${BLENDER_LOCKED} -b -Y "${CONSOLIDATED}"
-P ${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py --
"${CONSOLIDATED}" "${CONSOLIDATED}"
COMMAND ${CMAKE_COMMAND} -D FILE=${CONSOLIDATED}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${FINAL_DEPS}
${CMAKE_CURRENT_SOURCE_DIR}/consolidate.py
${CMAKE_CURRENT_SOURCE_DIR}/add_missing_shape_keys.py
${CMAKE_CURRENT_SOURCE_DIR}/transfer_shape_keys.py
${CMAKE_CURRENT_BINARY_DIR}/blender-addons-installed
COMMENT "Appending detailed nude parts and propagating shape keys from ${NUDE_SRC}"
)
else()
add_custom_command(
OUTPUT ${CONSOLIDATED}
COMMAND ${CMAKE_COMMAND} -E copy
"${CLOTHES_CONSOLIDATED}" "${CONSOLIDATED}"
COMMAND ${CMAKE_COMMAND} -D FILE=${CONSOLIDATED}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${FINAL_DEPS}
COMMENT "Staging final character blend for ${STEM}"
)
endif()
add_custom_command(
OUTPUT ${LODS}
COMMAND ${CMAKE_COMMAND} -E remove_directory ${OUTDIR}
@@ -546,10 +617,31 @@ endforeach()
add_custom_target(characters-all DEPENDS ${ALL_CHARACTER_TARGETS})
# CMake lists use semicolons; the shell would split --ages on them. The
# validator accepts commas, so pass a comma-separated list instead.
string(REPLACE ";" "," CHARACTER_ASSETS_AGES "${CHARACTER_BUILD_AGES}")
# Authoring helper: seed nude-<stem>.blend from the existing layer-0
# BodyTop/BodyBottom/BodyFeet parts. The helper refuses to overwrite an
# existing file unless --force is passed manually; these targets are
# opt-in and never part of the normal build.
foreach(AGE ${CHARACTER_AGES})
foreach(SEX ${CHARACTER_SEXES})
character_stem(${AGE} ${SEX} STEM)
add_custom_target(init-nude-${STEM}
COMMAND ${BLENDER_LOCKED} -b -Y
-P ${CMAKE_CURRENT_SOURCE_DIR}/make_nude_blend.py --
--age ${AGE} --sex ${SEX}
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Seeding nude-${STEM}.blend from the existing base parts")
endforeach()
endforeach()
add_custom_target(character-assets-check
COMMAND python3 ${CMAKE_CURRENT_SOURCE_DIR}/validate_age_assets.py
--characters-dir ${CMAKE_BINARY_DIR}/characters
--ages "${CHARACTER_BUILD_AGES}"
--source-dir ${CMAKE_CURRENT_SOURCE_DIR}
--ages "${CHARACTER_ASSETS_AGES}"
DEPENDS characters-all
COMMENT "Validating generated character age assets")
@@ -0,0 +1,2 @@
nude=@NUDE_STATE@
source=@NUDE_SRC@
Binary file not shown.
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Seed an optional ``nude-<stem>.blend`` from the existing layer-0 parts.
``nude-<stem>.blend`` holds the optional anatomically detailed body parts
for one age/sex band. This helper makes the first version for a stem by
copying the current composition bases (``BodyTop`` / ``BodyBottom`` /
``BodyFeet``) into a standalone blend, tagging them ``detailed-nude`` and
giving them unique object/data names. Edit the resulting file in Blender;
the normal character pipeline appends it after all clothing stages and the
runtime prefers those parts whenever a slot has no layer-1/2 garment.
Run through CMake (recommended; participates in the Blender lock)::
cmake --build build-vscode --target init-nude-male
or directly::
blender -b -Y -P assets/blender/characters/make_nude_blend.py -- \
--age adult --sex male [--force]
The helper never overwrites an existing file without ``--force``.
"""
from __future__ import annotations
import argparse
import os
import sys
import bpy
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
if _THIS_DIR not in sys.path:
sys.path.insert(0, _THIS_DIR)
import age_profiles # noqa: E402
import add_missing_shape_keys as shape_keys # noqa: E402
SLOT_ORDER = ("top", "bottom", "feet")
BASE_NAMES = {
"top": "BodyTop",
"bottom": "BodyBottom",
"feet": "BodyFeet",
}
CLOTHING_PROPS = (
"garments",
"_combined_meta",
"ref_layer",
"ref_part",
"garment_id",
"ref_age",
"ref_sex",
"ref_clothing",
"tags",
"own_skeleton",
"has_own_armature",
"own_armature_name",
)
def log(msg):
print("[nude-seed] " + msg, flush=True)
def parse_args():
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
p = argparse.ArgumentParser()
p.add_argument("--age", required=True,
choices=age_profiles.CANONICAL_AGES)
p.add_argument("--sex", required=True, choices=("male", "female"))
p.add_argument("--source", default="")
p.add_argument("--output", default="")
p.add_argument("--slots", default=",".join(SLOT_ORDER))
p.add_argument("--force", action="store_true")
return p.parse_args(argv)
def object_name_score(obj):
"""Prefer the canonical base name, then the shortest name.
Mirrors CharacterSlotSystem's layer-0 fallback: Blender duplicate
suffixes are penalised so ``BodyTop`` beats ``BodyTop.001``.
"""
name = obj.name
score = len(name)
if name.startswith(BASE_NAMES.get(obj.get("slot", ""), "\0")):
score -= 1000
if "." in name:
score += 1000
return score
def find_body_armature(stem):
arm = bpy.data.objects.get(stem)
if arm is not None and arm.type == 'ARMATURE':
return arm
candidates = [o for o in bpy.data.objects if o.type == 'ARMATURE']
if not candidates:
return None
return max(candidates, key=lambda o: len(o.data.bones))
def find_base_part(slot, age, sex):
candidates = []
for obj in bpy.data.objects:
if obj.type != 'MESH' or obj.data is None:
continue
if not all(k in obj for k in ("age", "sex", "slot")):
continue
if age_profiles.normalize_age(obj.get("age", "adult")) != age:
continue
if str(obj.get("sex", "")) != sex:
continue
if str(obj.get("slot", "")) != slot:
continue
if int(obj.get("layer", 0)) != 0:
continue
candidates.append(obj)
if not candidates:
return None
return min(candidates, key=object_name_score)
def find_shape_source():
"""Find the body reference mesh that carries the full shape-key set."""
for name in ("Body_shapes", "Body"):
obj = bpy.data.objects.get(name)
if obj is not None and obj.type == 'MESH' and \
obj.data.shape_keys is not None and \
len(obj.data.shape_keys.key_blocks) > 1:
return obj
return None
def build_shape_data(source_obj):
"""Same payload add_missing_shape_keys.py builds for its propagation."""
return {
"names": [kb.name for kb in source_obj.data.shape_keys.key_blocks],
"vertex_positions": {
sk.name: [(v.co.x, v.co.y, v.co.z) for v in sk.data]
for sk in source_obj.data.shape_keys.key_blocks
},
"polygons": [[v for v in poly.vertices]
for poly in source_obj.data.polygons],
"is_relative": source_obj.data.shape_keys.use_relative,
"source_obj_name": source_obj.name,
}
def duplicate_part(src, slot, age, sex, armature):
dup = src.copy()
dup.data = src.data.copy()
bpy.context.collection.objects.link(dup)
dup.name = src.name + "_detailed"
dup.data.name = dup.name
# Keep the source transform (the base parts are usually parented to the
# armature with an identity object matrix).
dup.parent = armature
dup.matrix_parent_inverse = src.matrix_parent_inverse.copy()
dup.matrix_world = src.matrix_world.copy()
for prop in CLOTHING_PROPS:
if prop in dup:
del dup[prop]
dup["age"] = age
dup["sex"] = sex
dup["slot"] = slot
dup["layer"] = 0
dup["clothing_tags"] = "detailed-nude"
mod = next((m for m in dup.modifiers if m.type == 'ARMATURE'), None)
if mod is None:
mod = dup.modifiers.new(name="Armature", type='ARMATURE')
mod.object = armature
if not dup.vertex_groups:
log(f"WARNING: '{dup.name}' has no vertex groups; skin it before "
f"building")
if dup.data.shape_keys is None:
log(f"WARNING: '{dup.name}' has no shape keys; copy them from "
f"the base part or use transfer_shape_keys.py")
return dup
def purge_orphans():
for _ in range(3):
try:
bpy.ops.outliner.orphans_purge(
do_local_ids=True, do_linked_ids=True,
do_recursive=True)
except Exception:
break
def main():
args = parse_args()
age = age_profiles.normalize_age(args.age)
sex = args.sex
stem = age_profiles.stem(age, sex)
source = args.source or os.path.join(
_THIS_DIR, f"edited-normal-{stem}.blend")
output = args.output or os.path.join(_THIS_DIR, f"nude-{stem}.blend")
if not os.path.exists(source):
log(f"ERROR: source body blend not found: {source}")
return 1
if os.path.exists(output) and not args.force:
log(f"SKIP: {output} already exists (use --force to reseed)")
return 0
slots = [s.strip() for s in args.slots.split(",") if s.strip()]
unknown = [s for s in slots if s not in SLOT_ORDER]
if unknown:
log(f"ERROR: unknown slots: {', '.join(unknown)}")
return 1
log(f"opening {source}")
bpy.ops.wm.open_mainfile(filepath=source)
armature = find_body_armature(stem)
if armature is None:
log(f"ERROR: no body armature found in {source}")
return 1
log(f"armature: {armature.name} ({len(armature.data.bones)} bones)")
shape_source = find_shape_source()
shape_data = build_shape_data(shape_source) if shape_source else None
if shape_source:
log(f"shape source: {shape_source.name} "
f"({len(shape_data['names'])} keys)")
else:
log("WARNING: no Body_shapes/Body shape-key source; the detailed "
"parts will only get keys already present on the base part")
duplicates = []
for slot in slots:
src = find_base_part(slot, age, sex)
if src is None:
log(f"WARNING: no layer-0 {slot} part found; skipping")
continue
dup = duplicate_part(src, slot, age, sex, armature)
if shape_data:
try:
shape_keys.add_missing_shape_keys(shape_data, dup)
except Exception as exc:
log(f"WARNING: shape-key propagation failed for "
f"{dup.name}: {exc}")
duplicates.append(dup)
log(f"{slot}: {src.name} -> {dup.name} "
f"({len(dup.data.vertices)} verts)")
if not duplicates:
log("ERROR: no layer-0 parts found for the requested slots")
return 1
# Keep exactly the new parts and the body armature. The user can model
# the detailed variants in this file without the rest of the body.
keep = set(duplicates) | {armature}
for obj in list(bpy.data.objects):
if obj not in keep:
bpy.data.objects.remove(obj, do_unlink=True)
# A source-blend armature may carry the full animation library; the
# detailed-nude file only needs the rest skeleton for skinning/editing.
armature.animation_data_clear()
for action in list(bpy.data.actions):
try:
bpy.data.actions.remove(action, do_unlink=True)
except TypeError:
bpy.data.actions.remove(action)
except Exception:
pass
purge_orphans()
os.makedirs(os.path.dirname(output), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=output)
log(f"saved {output}")
log("next: edit the *_detailed parts, then rebuild "
f"character-{stem} (or the full build)")
log("requirements: same armature/bind pose, max 4 bone influences, "
"shape keys, matching boundary loops")
return 0
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.
@@ -43,6 +43,9 @@ def warn(msg, warnings):
def main():
p = argparse.ArgumentParser()
p.add_argument("--characters-dir", required=True)
p.add_argument("--source-dir", default=THIS_DIR,
help="character source directory containing optional "
"nude-<stem>.blend files (default: %(default)s)")
p.add_argument("--ages", default="",
help="semicolon/comma separated age bands to check "
"(default: all canonical bands)")
@@ -69,6 +72,20 @@ def main():
for sex in ("male", "female"):
expected_stems.append(age_profiles.stem(age, sex))
# Catch misspelled detailed-nude source names (e.g. nude-adult-male.blend)
# instead of silently ignoring them.
all_stems = {age_profiles.stem(age, sex)
for age in age_profiles.CANONICAL_AGES
for sex in ("male", "female")}
source_root = os.path.abspath(args.source_dir)
for path in sorted(glob.glob(os.path.join(source_root, "nude-*.blend"))):
name = os.path.basename(path)
stem = name[len("nude-"):-len(".blend")]
if stem not in all_stems:
warn(f"unexpected detailed-nude source name: {name} "
f"(expected nude-<stem>.blend, e.g. nude-male.blend)",
warnings)
for stem in expected_stems:
d = by_stem.get(stem)
if d is None:
@@ -89,7 +106,9 @@ def main():
entries.append(e)
ages = {age_profiles.normalize_age(e.get("age", ""))
for e in entries}
if ages != {stem.split("-")[0] if "-" in stem else "adult"}:
stem_age = stem.split("-")[0] if "-" in stem else "adult"
stem_sex = stem.rsplit("-", 1)[-1]
if ages != {stem_age}:
fail(f"{stem}: catalog ages {sorted(ages)} do not match stem",
errors)
slots = {}
@@ -105,6 +124,67 @@ def main():
if "hair" not in slots:
warn(f"{stem}: no hair slot (bald band)", warnings)
# Optional detailed-nude source file: when present, the built
# catalog must expose its layer-0 parts. Missing slots are only a
# warning because a nude-<stem>.blend may be populated gradually.
nude_path = os.path.join(os.path.abspath(args.source_dir),
f"nude-{stem}.blend")
if os.path.exists(nude_path):
any_detailed = False
for req in ("top", "bottom", "feet"):
detailed = []
for e in slots.get(req, []):
tags = e.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(";")
if t.strip()]
if "detailed-nude" in tags:
detailed.append(e)
if not detailed:
warn(f"{stem}: nude-{stem}.blend has no "
f"detailed-nude {req} catalog entry (slot falls "
f"back to the composition base)", warnings)
continue
any_detailed = True
if len(detailed) > 1:
fail(f"{stem}: multiple detailed-nude {req} entries: "
+ ", ".join(e.get("mesh", "?")
for e in detailed), errors)
base_keys = set()
for e in slots.get(req, []):
tags = e.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(";")
if t.strip()]
if int(e.get("layer", 0)) == 0 and \
"detailed-nude" not in tags:
base_keys.update(e.get("shape_keys", []))
for e in detailed:
mesh = e.get("mesh", "?")
if int(e.get("layer", 0)) != 0:
fail(f"{stem}: detailed-nude {req} is not layer 0: "
f"{mesh}", errors)
if e.get("garments"):
fail(f"{stem}: detailed-nude {req} carries "
f"garments: {mesh}", errors)
if age_profiles.normalize_age(
e.get("age", "")) != stem_age:
fail(f"{stem}: detailed-nude {req} age mismatch: "
f"{mesh}", errors)
if str(e.get("sex", "")) != stem_sex:
fail(f"{stem}: detailed-nude {req} sex mismatch: "
f"{mesh}", errors)
missing_keys = base_keys - set(
e.get("shape_keys", []))
if missing_keys:
warn(f"{stem}: detailed-nude {req} is missing "
f"shape keys {sorted(missing_keys)}: {mesh}",
warnings)
if not any_detailed:
fail(f"{stem}: nude-{stem}.blend exists but the built "
f"catalog has no detailed-nude entries "
f"(re-run CMake and the character target)", errors)
skel = os.path.join(d, stem + ".skeleton")
if not os.path.exists(skel):
fail(f"{stem}: missing body skeleton {stem}.skeleton", errors)
+11
View File
@@ -1765,6 +1765,8 @@ void EditorApp::populateInteriorNpcs()
interiorGrid = e;
});
m_interiorNpcSystem->populate(interiorGrid);
m_interiorNpcRetryFrames =
m_interiorNpcSystem->needsRetry() ? 120 : 0;
}
void EditorApp::captureVehicleStates()
@@ -3066,6 +3068,15 @@ bool EditorApp::frameRenderingQueued(const Ogre::FrameEvent &evt)
}
stepProfile("npcPopulation");
/* A save/load can resolve the interior document before the
* population registry is rebuilt: retry any NPCs whose records
* were missing now that the population update has run. */
if (m_interiorNpcRetryFrames > 0 &&
m_interiorNpcSystem) {
--m_interiorNpcRetryFrames;
populateInteriorNpcs();
}
/* --- NPC vehicle traffic (demo-taxi phase 5.4/6.A): fleet
* seeding, clock-pure symbolic drive legs, distance-gated
* spawn/despawn. Runs after the population (needs the
+3
View File
@@ -721,6 +721,9 @@ private:
/* Phase 8.2 follow-up: schedule-driven NPCs in a generated
* interior (the exterior detail tier skips indoor NPCs). */
void populateInteriorNpcs();
/* Deferred retry after a save/load: the population registry is
* rebuilt one frame after the interior document is switched in. */
int m_interiorNpcRetryFrames = 0;
void applyVehicleStates(const Ogre::String &scenePath);
bool performSceneSwitch(const Ogre::String &scenePath,
const SceneSwitchOptions &opts);
@@ -7127,6 +7127,768 @@ struct FallTestListener : public Ogre::FrameListener {
}
};
/*
* --test-interiors (phase 8.5): generated-interior acceptance battery.
*
* Picks three enterable buildings in the streamed town (multi-storey,
* exterior-stair and back-door capable where present) and checks the
* full contract: interior footprint/stairs/arrival/exit wiring, two
* visits returning a bit-identical cached document, a plan edit
* invalidating the cache, and save/load resuming inside the SAME
* generated interior with population + clock restored.
*
* Run: ./demoTaxi --headless --test-interiors
*/
struct InteriorsAcceptanceListener : public Ogre::FrameListener {
EditorApp *app;
int frame = 0;
int phase = 0;
bool failed = false;
Ogre::String failReason;
int deadline = 90000;
int wait = 0;
size_t current = 0;
bool clockSet = false;
bool secondEntry = false;
bool travelIssued = false;
struct TestCase {
std::string uid;
std::string scheme;
std::string doorA;
std::string doorB;
int floors = 0;
bool exteriorStair = false;
Ogre::Vector3 entryPos = Ogre::Vector3::ZERO;
nlohmann::json firstDoc;
uint64_t firstHash = 0;
size_t firstCacheSize = 0;
};
std::vector<TestCase> cases;
std::string baseScene;
std::string savePath = "/tmp/opencode/interiors_acceptance_save.json";
Ogre::Vector3 savedPlayerPos = Ogre::Vector3::ZERO;
size_t savedPopulation = 0;
float savedHours = 0.0f;
Ogre::Vector3 expectedExit = Ogre::Vector3::ZERO;
explicit InteriorsAcceptanceListener(EditorApp *a)
: app(a)
{
}
bool fail(const std::string &why)
{
if (failed)
return true;
failed = true;
failReason = why;
std::cerr << "[test] interiors FAIL: " << why << std::endl;
app->getRoot()->queueEndRendering();
return true;
}
Ogre::Vector3 worldPos(flecs::entity e)
{
if (!e.is_alive() || !e.has<TransformComponent>())
return Ogre::Vector3::ZERO;
const auto &t = e.get<TransformComponent>();
const Ogre::Vector3 rp =
t.node ? t.node->_getDerivedPosition() : t.position;
JPH::DVec3 wp(rp.x, rp.y, rp.z);
if (RenderOriginSystem *ro =
RenderOriginSystem::getInstance())
wp = ro->renderToWorld(rp);
return Ogre::Vector3((float)wp.GetX(), (float)wp.GetY(),
(float)wp.GetZ());
}
flecs::entity exteriorBuilding(const std::string &uid)
{
flecs::entity found;
app->getWorld()->each(
[&](flecs::entity e, CellGridComponent &g) {
if (g.gridUid == uid &&
g.generationMode == "exteriorOnly")
found = e;
});
return found;
}
flecs::entity interiorGrid(const std::string &uid)
{
flecs::entity found;
app->getWorld()->each(
[&](flecs::entity e, CellGridComponent &g) {
if (g.gridUid == uid &&
g.generationMode == "interiorOnly")
found = e;
});
return found;
}
static void cellBounds(const LayoutLib::BuildingPlan &p, int &minX,
int &maxX, int &minZ, int &maxZ)
{
minX = maxX = minZ = maxZ = -1;
for (int y = 0; y < p.floors; ++y) {
for (int z = 0; z < p.cellsZ; ++z) {
for (int x = 0; x < p.cellsX; ++x) {
if (!(p.at(x, y, z) &
LayoutLib::PlanFlags::Floor))
continue;
if (minX < 0) {
minX = maxX = x;
minZ = maxZ = z;
} else {
minX = std::min(minX, x);
maxX = std::max(maxX, x);
minZ = std::min(minZ, z);
maxZ = std::max(maxZ, z);
}
}
}
}
}
static void cellBounds(const CellGridComponent &g, int &minX,
int &maxX, int &minZ, int &maxZ)
{
minX = maxX = minZ = maxZ = -1;
for (const Cell &c : g.cells) {
if (!(c.flags & CellFlags::Floor))
continue;
if (minX < 0) {
minX = maxX = c.x;
minZ = maxZ = c.z;
} else {
minX = std::min(minX, c.x);
maxX = std::max(maxX, c.x);
minZ = std::min(minZ, c.z);
maxZ = std::max(maxZ, c.z);
}
}
}
void collectCases()
{
cases.clear();
bool haveMulti = false, haveExterior = false, haveBack = false;
app->getWorld()->each(
[&](flecs::entity e, CellGridComponent &g) {
if (g.generationMode != "exteriorOnly" ||
g.gridUid.rfind("town-", 0) != 0 ||
g.cells.empty())
return;
std::string first, second;
for (const auto &dc : g.doorConfigs) {
if (dc.second.sceneSwitchPath
.rfind("generated:", 0) != 0)
continue;
if (first.empty())
first = dc.first;
else if (second.empty() &&
dc.first != first)
second = dc.first;
}
if (first.empty())
return;
const bool multi = g.height > 1;
bool exteriorStair = false;
for (const Cell &c : g.cells)
if (c.flags & CellFlags::ExteriorStair)
exteriorStair = true;
const bool back = !second.empty();
const bool categorized =
(multi && !haveMulti) ||
(exteriorStair && !haveExterior) ||
(back && !haveBack);
if (!categorized && cases.size() >= 3)
return;
TestCase tc;
tc.uid = g.gridUid;
tc.scheme = "generated:" + g.gridUid;
tc.doorA = first;
tc.doorB = second.empty() ? first : second;
tc.floors = g.height;
tc.exteriorStair = exteriorStair;
/* Front-door approach point (world space):
* the shell spans [0, w*cs] from the node,
* front face at local +Z (TownGenSystem
* convention). Used to travel back into
* streaming range before entering. */
const auto &t = e.get<TransformComponent>();
const Ogre::Vector3 rp =
t.node ? t.node->_getDerivedPosition() :
t.position;
const Ogre::Quaternion rq =
t.node ? t.node->_getDerivedOrientation() :
t.rotation;
const float cs = g.cellSize;
const Ogre::Vector3 shellCenter(
(g.width - 1) * cs * 0.5f, 0.0f,
(g.depth - 1) * cs * 0.5f);
const Ogre::Vector3 approach =
rp + rq * shellCenter +
rq * Ogre::Vector3(
0.0f, 0.0f,
g.depth * cs * 0.5f + 3.0f);
JPH::DVec3 wp(approach.x, approach.y,
approach.z);
if (RenderOriginSystem *ro =
RenderOriginSystem::getInstance())
wp = ro->renderToWorld(approach);
tc.entryPos = Ogre::Vector3((float)wp.GetX(),
(float)wp.GetY(),
(float)wp.GetZ());
cases.push_back(tc);
if (multi)
haveMulti = true;
if (exteriorStair)
haveExterior = true;
if (back)
haveBack = true;
});
}
bool enter(const TestCase &tc, const std::string &door)
{
secondEntry = (phase == 2);
if (!secondEntry &&
!exteriorBuilding(tc.uid).is_alive()) {
/* The streamed town only keeps pages around the
* camera loaded: travel back into this building's
* range first (a player would have walked up to
* the door), then wait for the page to stream
* in. */
if (!travelIssued) {
SceneSwitchOptions travel;
travel.hasPosition = true;
travel.posX = tc.entryPos.x;
travel.posY = tc.entryPos.y;
travel.posZ = tc.entryPos.z;
if (!app->switchScene(baseScene, travel))
return fail("travel switch refused");
travelIssued = true;
wait = 0;
return true;
}
if (wait % 300 == 0) {
const Ogre::Vector3 pp = worldPos(
app->getPlayerCharacterEntity());
std::string grids;
app->getWorld()->each(
[&](flecs::entity, CellGridComponent &g) {
if (g.generationMode ==
"exteriorOnly")
grids += " " + g.gridUid;
});
std::cout << "[test] exterior grids:" << grids
<< std::endl;
TerrainSystem *ts =
TerrainSystem::getInstance();
if (ts && ts->getTerrainGroup()) {
int loaded = 0;
for (const auto &kv :
ts->getTerrainGroup()
->getTerrainSlots())
if (kv.second &&
kv.second->instance &&
kv.second->instance
->isLoaded())
++loaded;
std::cout << "[test] terrain slots "
<< loaded << " streaming "
<< ts->getStreamingActive()
<< std::endl;
}
std::cout << "[test] interiors: travel wait "
<< wait << " player "
<< pp.x << "," << pp.y << "," << pp.z
<< " target " << tc.entryPos.x << ","
<< tc.entryPos.y << "," << tc.entryPos.z
<< " pending "
<< app->hasPendingSceneSwitch()
<< std::endl;
}
if (++wait > 12000)
return fail("building did not stream in "
"for entry: " + tc.uid);
return true;
}
if (!secondEntry) {
/* Resolve once from the exterior (the building
* entity is present here) and remember the
* authoritative cached document. */
GeneratedInteriorService *svc =
app->getGeneratedInteriorService();
nlohmann::json doc;
if (!svc ||
!svc->resolveGeneratedInterior(tc.scheme,
baseScene, doc))
return fail("could not resolve the interior "
"document");
TestCase &mut = cases[current];
mut.firstDoc = doc;
mut.firstHash =
doc.value("interior", nlohmann::json())
.value("planHash", (uint64_t)0);
mut.firstCacheSize = svc->cache().size();
}
travelIssued = false;
SceneSwitchOptions opts;
opts.targetEntityName = "arrival:" + door;
if (!app->switchScene(tc.scheme, opts))
return fail("entry refused for " + tc.scheme);
wait = 0;
phase = 1;
return true;
}
bool checkInside(const TestCase &tc, const std::string &door)
{
flecs::entity interior = interiorGrid(tc.uid);
if (!interior.is_alive())
return true; /* still loading */
const CellGridComponent &g = interior.get<CellGridComponent>();
const LayoutLib::BuildingPlan *plan =
TownGenSystem::findBuildingPlan(tc.uid);
if (!plan)
return fail("interior plan missing for " + tc.uid);
if ((int)g.height != tc.floors)
return fail("interior floors differ for " + tc.uid);
int pMinX, pMaxX, pMinZ, pMaxZ;
int gMinX, gMaxX, gMinZ, gMaxZ;
cellBounds(*plan, pMinX, pMaxX, pMinZ, pMaxZ);
cellBounds(g, gMinX, gMaxX, gMinZ, gMaxZ);
if (pMinX != gMinX || pMaxX != gMaxX || pMinZ != gMinZ ||
pMaxZ != gMaxZ)
return fail("interior footprint differs from plan " +
tc.uid);
if (tc.floors > 1) {
int stairs = 0;
for (const Cell &c : g.cells)
if (c.flags & CellFlags::StairWell)
++stairs;
if (stairs == 0)
return fail("multi-storey interior has no "
"stairs for " + tc.uid);
}
flecs::entity marker;
app->getWorld()->each(
[&](flecs::entity e, EntityNameComponent &n) {
if (n.name == "arrival:" + door)
marker = e;
});
if (!marker.is_alive() ||
!marker.has<TransformComponent>())
return fail("arrival marker missing for " + door);
const auto &mt = marker.get<TransformComponent>();
const Ogre::Vector3 mp =
mt.node ? mt.node->_getDerivedPosition() : mt.position;
const float half = g.cellSize * 0.5f;
if (mp.x < gMinX * g.cellSize - half - 0.3f ||
mp.x > gMaxX * g.cellSize + half + 0.3f ||
mp.z < gMinZ * g.cellSize - half - 0.3f ||
mp.z > gMaxZ * g.cellSize + half + 0.3f ||
mp.y < -0.5f || mp.y > g.height * g.cellHeight + 1.0f)
return fail("arrival marker is outside the interior " +
door);
for (const auto &dc : g.doorConfigs) {
if (dc.second.sceneSwitchPath.rfind("generated:", 0) == 0)
continue;
if (!dc.second.hasSceneSwitchPose ||
dc.second.sceneSwitchPath != baseScene)
return fail("exit door is not pre-wired to the "
"source scene for " + tc.uid);
}
InteriorNpcSystem *ins = app->getInteriorNpcSystem();
if (!ins)
return fail("no InteriorNpcSystem");
const size_t eligible = ins->eligibleCount();
const size_t spawned = ins->spawnedCount();
if (spawned != std::min<size_t>(eligible, 12))
return fail("inside NPC count " +
std::to_string(spawned) + " != eligible " +
std::to_string(eligible));
for (flecs::entity_t id : ins->spawned()) {
flecs::entity npc = app->getWorld()->entity(id);
if (!npc.is_alive() ||
!npc.has<TransformComponent>())
return fail("inside NPC entity missing");
const auto &t = npc.get<TransformComponent>();
const Ogre::Vector3 p =
t.node ? t.node->_getDerivedPosition() :
t.position;
bool anchored = false;
interior.children([&](flecs::entity c) {
if (!c.has<RoomComponent>())
return true;
const auto &r = c.get<RoomComponent>();
if (r.roomType == "stairwell")
return true;
if (p.x >= r.minX * g.cellSize - 0.8f &&
p.x <= r.maxX * g.cellSize + 0.8f &&
p.z >= r.minZ * g.cellSize - 0.8f &&
p.z <= r.maxZ * g.cellSize + 0.8f &&
p.y >= r.minY * g.cellHeight - 0.3f &&
p.y <= r.maxY * g.cellHeight + 0.3f)
anchored = true;
return true;
});
if (!anchored)
return fail("inside NPC is not at a room "
"anchor for " + tc.uid);
}
return true;
}
bool checkCachedDocument(const TestCase &tc, bool first)
{
GeneratedInteriorService *svc =
app->getGeneratedInteriorService();
if (!svc)
return fail("no GeneratedInteriorService");
nlohmann::json doc;
if (!svc->resolveGeneratedInterior(tc.scheme, baseScene, doc))
return fail("could not resolve the interior document");
const uint64_t hash =
doc.value("interior", nlohmann::json())
.value("planHash", (uint64_t)0);
(void)first;
if (doc != tc.firstDoc || hash != tc.firstHash)
return fail("second visit did not return the cached "
"document for " + tc.uid);
if (svc->cache().size() != tc.firstCacheSize)
return fail("cache grew on the second visit for " +
tc.uid);
return true;
}
bool exitToExterior(const TestCase &tc, const std::string &door)
{
flecs::entity interior = interiorGrid(tc.uid);
if (!interior.is_alive())
return fail("interior vanished before exit");
const auto &g = interior.get<CellGridComponent>();
auto it = g.doorConfigs.find(door);
if (it == g.doorConfigs.end() ||
!it->second.hasSceneSwitchPose)
return fail("no exit pose for " + door);
expectedExit = Ogre::Vector3(it->second.sceneSwitchPosX,
it->second.sceneSwitchPosY,
it->second.sceneSwitchPosZ);
SceneSwitchOptions back;
back.hasPosition = true;
back.posX = expectedExit.x;
back.posY = expectedExit.y;
back.posZ = expectedExit.z;
back.hasRotation = true;
back.rotation = Ogre::Quaternion(
Ogre::Degree(it->second.sceneSwitchYawDeg),
Ogre::Vector3::UNIT_Y);
if (!app->switchScene(baseScene, back))
return fail("exit switch refused");
wait = 0;
phase = 2;
return true;
}
bool checkExteriorReturn()
{
if (app->isInsideGeneratedInterior() ||
app->hasPendingSceneSwitch())
return true;
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() ||
!player.has<TransformComponent>())
return true;
const Ogre::Vector3 p = worldPos(player);
if ((p - expectedExit).length() > 1.2f)
return fail("player did not land at the approach pose " +
Ogre::StringConverter::toString(p) +
" want " +
Ogre::StringConverter::toString(
expectedExit));
if (app->getCurrentBaseScene() != baseScene)
return fail("base scene changed on exit");
return true;
}
bool frameRenderingQueued(const Ogre::FrameEvent &) override
{
++frame;
if (failed || phase == 99) {
app->getRoot()->queueEndRendering();
return true;
}
if (frame > deadline)
return fail("timeout in phase " +
std::to_string(phase));
if (!clockSet) {
if (GameClockSystem *clock = GameClockSystem::getInstance()) {
clock->setPaused(true);
clock->setTime(12, 12.0f);
}
clockSet = true;
}
switch (phase) {
case 0: {
collectCases();
bool multi = false, ext = false, back = false;
for (const auto &tc : cases) {
if (tc.floors > 1)
multi = true;
if (tc.exteriorStair)
ext = true;
if (tc.doorA != tc.doorB)
back = true;
}
if (cases.size() < 3 || !multi) {
if (wait % 300 == 0) {
int grids = 0, exteriorGrids = 0, doors = 0;
app->getWorld()->each(
[&](flecs::entity, CellGridComponent &g) {
++grids;
if (g.generationMode == "exteriorOnly") {
++exteriorGrids;
doors += (int)g.doorConfigs.size();
}
});
std::cout << "[test] interiors: waiting, "
<< cases.size() << " cases (multi "
<< multi << ", ext " << ext
<< ", back " << back << ") grids "
<< grids << " exterior "
<< exteriorGrids << " doors " << doors
<< " scene " << app->getCurrentBaseScene()
<< std::endl;
}
if (++wait > 12000)
return fail("not enough enterable "
"buildings appeared");
return true;
}
if (!ext || !back)
std::cout << "[test] interiors: optional case "
"missing (exteriorStair="
<< ext << ", backDoor=" << back
<< ")" << std::endl;
baseScene = app->getCurrentBaseScene();
std::cout << "[test] interiors: " << cases.size()
<< " buildings selected" << std::endl;
current = 0;
wait = 0;
phase = 7;
return true;
}
case 7: {
if (app->hasPendingSceneSwitch())
return true;
return enter(cases[current], cases[current].doorA);
}
case 1: {
if (!app->isInsideGeneratedInterior())
return true;
flecs::entity interior = interiorGrid(cases[current].uid);
if (!interior.is_alive())
return true;
if (!checkInside(cases[current], cases[current].doorA))
return true;
if (failed)
return true;
if (current == 0 && !secondEntry) {
/* Save/load inside: record state, then reload. */
savedPlayerPos = worldPos(
app->getPlayerCharacterEntity());
if (NpcPopulationSystem *pop =
NpcPopulationSystem::getInstance())
savedPopulation =
pop->populationCharacterIds()
.size();
if (GameClockSystem *clock =
GameClockSystem::getInstance())
savedHours =
clock->getAbsoluteHours();
app->saveGame(savePath, "interiors-acceptance");
app->loadGame(savePath);
wait = 0;
phase = 3;
return true;
}
return exitToExterior(cases[current],
cases[current].doorA);
}
case 2: {
if (app->isInsideGeneratedInterior() ||
app->hasPendingSceneSwitch())
return true;
flecs::entity player = app->getPlayerCharacterEntity();
if (!player.is_alive() ||
!player.has<TransformComponent>())
return true;
if (!checkExteriorReturn())
return true;
if (failed)
return true;
if (secondEntry) {
phase = 5;
return true;
}
/* The exit switch reloads the exterior; the town
* streams the building's page in around the camera.
* Wait for it before resolving. */
if (!exteriorBuilding(cases[current].uid).is_alive()) {
if (++wait > 6000)
return fail("exterior building did not "
"stream back in");
return true;
}
wait = 0;
/* Second visit: bit-identical cached document, and a
* second (back) door when the layout has one. */
if (!checkCachedDocument(cases[current], false))
return true;
if (failed)
return true;
return enter(cases[current], cases[current].doorB);
}
case 3: {
/* Deferred load-game interior re-entry. */
if (!app->isInsideGeneratedInterior())
return true;
InteriorNpcSystem *ins = app->getInteriorNpcSystem();
if (ins && ins->needsRetry()) {
if (++wait > 600)
return fail("interior NPC records were "
"not rebuilt after load");
return true;
}
if (!checkInside(cases[0], cases[0].doorA))
return true;
const Ogre::Vector3 p = worldPos(
app->getPlayerCharacterEntity());
if ((p - savedPlayerPos).length() > 0.8f)
return fail("load did not resume at the saved "
"pose");
if (NpcPopulationSystem *pop =
NpcPopulationSystem::getInstance()) {
if (pop->populationCharacterIds().size() !=
savedPopulation)
return fail("population was not restored "
"on load");
}
if (GameClockSystem *clock =
GameClockSystem::getInstance()) {
if (std::fabs(clock->getAbsoluteHours() -
savedHours) > 0.05f)
return fail("clock was not restored on "
"load");
}
if (app->getCurrentBaseScene() != baseScene)
return fail("base scene changed on load");
/* The exterior entities are gone while inside, so
* resolve against the plan registry directly: the
* reloaded town must have regenerated the building
* bit-identically (same plan hash). */
const LayoutLib::BuildingPlan *plan =
TownGenSystem::findBuildingPlan(cases[0].uid);
if (!plan)
return fail("interior plan missing after load");
const std::string &uid = cases[0].uid;
const std::string addr =
uid.rfind("town-", 0) == 0 ? uid.substr(5) : uid;
const uint64_t hash = InteriorScene::planHash(*plan, addr);
if (hash != cases[0].firstHash)
return fail("exterior regenerated differently on "
"load");
return exitToExterior(cases[0], cases[0].doorA);
}
case 4: {
/* Not used (kept for phase numbering). */
phase = 5;
return true;
}
case 5: {
/* All three buildings visited twice. */
++current;
wait = 0;
if (current < cases.size()) {
phase = 7;
return true;
}
phase = 6;
return true;
}
case 6: {
/* Plan edit: the cache key includes the plan hash, so
* the document must change (the same path a lot-param
* regeneration takes). */
/* Use the last visited building: its exterior entity
* is still streamed in (we just exited at its door),
* while cases[0]'s page may already be unloaded. */
const TestCase &tc = cases.back();
if (!exteriorBuilding(tc.uid).is_alive()) {
if (++wait > 6000)
return fail("exterior building did not "
"stream back in");
return true;
}
wait = 0;
const LayoutLib::BuildingPlan *old =
TownGenSystem::findBuildingPlan(tc.uid);
if (!old)
return fail("plan missing for cache invalidation");
LayoutLib::BuildingPlan edited = *old;
bool toggled = false;
for (int y = 0; y < edited.floors && !toggled; ++y)
for (int z = 0; z < edited.cellsZ && !toggled;
++z)
for (int x = 0; x < edited.cellsX;
++x) {
if (!(edited.at(x, y, z) &
LayoutLib::PlanFlags::
Floor))
continue;
edited.at(x, y, z) ^=
LayoutLib::PlanFlags::
Ceiling;
toggled = true;
break;
}
if (!toggled)
return fail("plan has no floor cell to edit");
TownGenSystem::registerBuildingPlan(tc.uid, edited);
GeneratedInteriorService *svc =
app->getGeneratedInteriorService();
nlohmann::json doc;
if (!svc ||
!svc->resolveGeneratedInterior(tc.scheme, baseScene,
doc))
return fail("could not resolve the edited plan");
const uint64_t hash =
doc.value("interior", nlohmann::json())
.value("planHash", (uint64_t)0);
if (hash == tc.firstHash || doc == tc.firstDoc)
return fail("plan edit did not invalidate the "
"interior cache");
std::cout << "[test] interiors: cache invalidation ok"
<< std::endl;
phase = 99;
std::cout << "[test] PASS: 3 interiors entered/exited, "
"cached docs stable, save/load inside, "
"cache invalidation"
<< std::endl;
app->getRoot()->queueEndRendering();
return true;
}
}
return true;
}
};
/*
* --test-stolen-car (phase 8.2 follow-up): carjacked NPC car ownership.
*
@@ -14498,6 +15260,7 @@ int main(int argc, char *argv[])
bool testFurniture = false;
bool testStairs = false;
bool testFall = false;
bool testInteriors = false;
bool testInterior = false;
bool testStolenCar = false;
bool testTownInterior = false;
@@ -14591,6 +15354,8 @@ int main(int argc, char *argv[])
testStairs = true;
} else if (arg == "--test-fall") {
testFall = true;
} else if (arg == "--test-interiors") {
testInteriors = true;
} else if (arg == "--test-interior") {
testInterior = true;
} else if (arg == "--test-stolen-car") {
@@ -14724,6 +15489,9 @@ int main(int argc, char *argv[])
/* The real-town interior probe needs the streamed town. */
if (testTownInterior && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The phase 8.5 acceptance battery needs it too. */
if (testInteriors && !sceneArgGiven)
sceneFile = "demo_scene_exterior.json";
/* The detail-tier test too. */
/* The detail-tier test too. */
if (testDetail && !sceneArgGiven)
@@ -14973,6 +15741,11 @@ int main(int argc, char *argv[])
if (testFall)
app.getRoot()->addFrameListener(&fallListener);
/* Phase 8.5: generated-interior acceptance battery. */
InteriorsAcceptanceListener interiorsListener(&app);
if (testInteriors)
app.getRoot()->addFrameListener(&interiorsListener);
/* Phase 8.2: in-memory generated interior switching. */
InteriorTestListener interiorListener(&app, sceneFile);
if (testInterior)
@@ -803,8 +803,12 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
return flecs::entity::null();
CharacterRecord *c = findCharacter(id);
if (!c)
if (!c) {
Ogre::LogManager::getSingleton().logMessage(
"CharacterRegistry: spawnCharacter " + std::to_string(id) +
" failed: record missing");
return flecs::entity::null();
}
/* Singleton: despawn any existing copy first */
flecs::entity existing = findSpawnedEntity(id);
@@ -832,6 +836,10 @@ flecs::entity CharacterRegistry::spawnCharacter(uint64_t id)
inst = prefabSys.createInstance(
c->prefabPath, flecs::entity::null(), pos,
c->firstName + " " + c->lastName, m_uiSystem);
if (!inst.is_alive())
Ogre::LogManager::getSingleton().logMessage(
"CharacterRegistry: prefab spawn failed for " +
c->prefabPath);
if (inst.is_alive() && inst.has<TransformComponent>()) {
auto &t = inst.get_mut<TransformComponent>();
t.position = c->position;
@@ -58,7 +58,13 @@ GeneratedInteriorService::findBuilding(const std::string &gridUid) const
{
flecs::entity found;
m_world.each([&](flecs::entity e, CellGridComponent &grid) {
if (!found.is_alive() && grid.gridUid == gridUid)
/* Only the EXTERIOR shell supplies the entrance poses and
* the world transform; while inside, the interior grid
* carries the same uid and must not be mistaken for it
* (it would produce a document with origin-space exit
* poses). */
if (!found.is_alive() && grid.gridUid == gridUid &&
grid.generationMode == "exteriorOnly")
found = e;
});
return found;
@@ -105,6 +105,7 @@ int InteriorNpcSystem::populate(flecs::entity interiorGrid)
if (eligibleInBuilding(pop, motion, id, address))
eligible.push_back(id);
m_eligible = eligible.size();
m_missing = 0;
if (eligible.empty()) {
Ogre::LogManager::getSingleton().logMessage(
"InteriorNpcSystem: no scheduled NPC is inside '" +
@@ -119,11 +120,24 @@ int InteriorNpcSystem::populate(flecs::entity interiorGrid)
for (size_t i = 0; i < eligible.size() && spawned < (int)kMaxInteriorNpcs;
++i) {
const uint64_t id = eligible[i];
if (registry.findSpawnedEntity(id).is_alive())
continue; /* already materialized */
flecs::entity npc = registry.spawnCharacter(id);
if (!npc.is_alive())
if (!registry.findCharacter(id)) {
/* The population system rebuilds its registry records a
* frame after a save/load (applyLoadedSaveState clears the
* registry). Defer this NPC to the retry pass rather than
* treating the interior as empty. */
++m_missing;
continue;
}
/* spawnCharacter() replaces any already-materialized copy
* (persistent save spawns and previous interior visits alike),
* so the count and the interior tag always match. */
flecs::entity npc = registry.spawnCharacter(id);
if (!npc.is_alive()) {
Ogre::LogManager::getSingleton().logMessage(
"InteriorNpcSystem: spawn failed for " +
std::to_string(id));
continue;
}
const RoomAnchor &anchor = anchors[i % anchors.size()];
/* Spread roommates in a 3x3 pattern inside the centre cell
@@ -43,6 +43,17 @@ public:
{
return m_eligible;
}
/** Eligible ids whose registry record did not exist yet (the
* population system rebuilds those records a frame later after a
* save/load; EditorApp retries populate then). */
size_t missingRecords() const
{
return m_missing;
}
bool needsRetry() const
{
return m_missing > 0;
}
const std::vector<flecs::entity_t> &spawned() const
{
return m_spawned;
@@ -53,4 +64,5 @@ private:
EditorApp *m_app;
std::vector<flecs::entity_t> m_spawned;
size_t m_eligible = 0;
size_t m_missing = 0;
};
@@ -934,7 +934,34 @@ void TownGenSystem::update()
if (terrain.is_alive() && terrain.has<TerrainComponent>())
graph = &terrain.get<TerrainComponent>().roadGraph;
{
static int s_dbgFrames = 0;
if ((!graph || graph->townAreas.empty()) &&
s_dbgFrames < 40) {
++s_dbgFrames;
Ogre::LogManager::getSingleton().logMessage(
"TownGenSystem: early-out active=" +
std::to_string((int)active) + " rs=" +
std::to_string((int)(rs != nullptr)) +
" terrainAlive=" +
std::to_string((int)terrain.is_alive()) +
" graph=" +
std::to_string((int)(graph != nullptr)) +
" areas=" +
(graph ? std::to_string(
graph->townAreas.size()) :
std::string("-")) +
" nodes=" +
(graph ? std::to_string(graph->nodes.size()) :
std::string("-")));
}
}
if (!graph || graph->townAreas.empty()) {
if (m_streamTerrainId != 0)
Ogre::LogManager::getSingleton().logMessage(
"TownGenSystem: no graph/town areas; "
"dropping streamed town");
clearStreamedPages();
m_streamTerrainId = 0;
return;
@@ -943,6 +970,11 @@ void TownGenSystem::update()
/* Scene switches rebuild the terrain entity; the containers of
* the old scene died with it, so restart the bookkeeping. */
if (m_streamTerrainId != terrain.id()) {
Ogre::LogManager::getSingleton().logMessage(
"TownGenSystem: terrain rebuilt; restarting "
"streamed-town bookkeeping (" +
std::to_string(graph->townAreas.size()) +
" area(s))");
clearStreamedPages();
m_streamTerrainId = terrain.id();
}
@@ -1034,6 +1066,11 @@ void TownGenSystem::buildStreamedPageArea(const RoadGraph &graph,
if (result.buildings.empty())
return;
Ogre::LogManager::getSingleton().logMessage(
"TownGenSystem[" + area.name + " p" + std::to_string(px) +
"_" + std::to_string(py) + "]: generating " +
std::to_string(result.buildings.size()) + " building(s)");
const TownGen::DistrictParams &dp =
TownGen::districtParams(p.district);
flecs::entity container = createTownContainer(