From 8fbe9d1255f2c14c6be8a023e1cb3a9212c089ff Mon Sep 17 00:00:00 2001 From: Sergey Lapin Date: Sun, 1 Nov 2026 20:52:39 +0300 Subject: [PATCH] Phase 8 checkpoint --- AGENTS.md | 6 + .../blender/characters/CHARACTER_MODELING.md | 144 +++- assets/blender/characters/CMakeLists.txt | 96 ++- assets/blender/characters/cmake/nude_state.in | 2 + .../edited-normal-baby-female.blend | 4 +- assets/blender/characters/make_nude_blend.py | 290 +++++++ assets/blender/characters/nude-male.blend | 3 + .../blender/characters/validate_age_assets.py | 82 +- src/features/editScene/EditorApp.cpp | 11 + src/features/editScene/EditorApp.hpp | 3 + .../editScene/demos/demo-taxi/demo_main.cpp | 773 ++++++++++++++++++ .../editScene/systems/CharacterRegistry.cpp | 10 +- .../systems/GeneratedInteriorService.cpp | 8 +- .../editScene/systems/InteriorNpcSystem.cpp | 22 +- .../editScene/systems/InteriorNpcSystem.hpp | 12 + .../editScene/systems/TownGenSystem.cpp | 37 + 16 files changed, 1449 insertions(+), 54 deletions(-) create mode 100644 assets/blender/characters/cmake/nude_state.in create mode 100644 assets/blender/characters/make_nude_blend.py create mode 100644 assets/blender/characters/nude-male.blend diff --git a/AGENTS.md b/AGENTS.md index dcaa16f..6553573 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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-.blend` detailed-nude source files from the existing + layer-0 body parts (`init-nude-` 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 diff --git a/assets/blender/characters/CHARACTER_MODELING.md b/assets/blender/characters/CHARACTER_MODELING.md index 028f63b..622b552 100644 --- a/assets/blender/characters/CHARACTER_MODELING.md +++ b/assets/blender/characters/CHARACTER_MODELING.md @@ -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--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--consolidated-clothes.blend`. If + `nude-.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--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-.blend`) + +Detailed-nude parts live in an optional, separate source blend per +age/sex stem: `nude-.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-.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-.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-.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-.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_.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-`, `characters-all`, or the + full build). No CMake edits are needed: `CMakeLists.txt` detects + `nude-.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-.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-.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-`, 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. diff --git a/assets/blender/characters/CMakeLists.txt b/assets/blender/characters/CMakeLists.txt index ce165b1..fac2951 100644 --- a/assets/blender/characters/CMakeLists.txt +++ b/assets/blender/characters/CMakeLists.txt @@ -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-.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-.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-.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") diff --git a/assets/blender/characters/cmake/nude_state.in b/assets/blender/characters/cmake/nude_state.in new file mode 100644 index 0000000..b07a15b --- /dev/null +++ b/assets/blender/characters/cmake/nude_state.in @@ -0,0 +1,2 @@ +nude=@NUDE_STATE@ +source=@NUDE_SRC@ diff --git a/assets/blender/characters/edited-normal-baby-female.blend b/assets/blender/characters/edited-normal-baby-female.blend index bf0a507..efd2aed 100644 --- a/assets/blender/characters/edited-normal-baby-female.blend +++ b/assets/blender/characters/edited-normal-baby-female.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0afd20804330a1179a7447c7d4f0bbeed90d5c669bfe82c771a1f6bc737e3dd7 -size 21254971 +oid sha256:b3479453354e21c68ae5a61d4f4443265eb6d422772d2b125d6025aed967f60c +size 21204244 diff --git a/assets/blender/characters/make_nude_blend.py b/assets/blender/characters/make_nude_blend.py new file mode 100644 index 0000000..818a53c --- /dev/null +++ b/assets/blender/characters/make_nude_blend.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Seed an optional ``nude-.blend`` from the existing layer-0 parts. + +``nude-.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()) diff --git a/assets/blender/characters/nude-male.blend b/assets/blender/characters/nude-male.blend new file mode 100644 index 0000000..a01737d --- /dev/null +++ b/assets/blender/characters/nude-male.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f20939b82a45cd99544b5a0dd92e3162c6b0c45dc0681fc28b2a86f00825866 +size 6581908 diff --git a/assets/blender/characters/validate_age_assets.py b/assets/blender/characters/validate_age_assets.py index b1d59cd..f05ffe2 100644 --- a/assets/blender/characters/validate_age_assets.py +++ b/assets/blender/characters/validate_age_assets.py @@ -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-.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-.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-.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) diff --git a/src/features/editScene/EditorApp.cpp b/src/features/editScene/EditorApp.cpp index 70c841c..2026f65 100644 --- a/src/features/editScene/EditorApp.cpp +++ b/src/features/editScene/EditorApp.cpp @@ -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 diff --git a/src/features/editScene/EditorApp.hpp b/src/features/editScene/EditorApp.hpp index 0790d75..0a2dfc0 100644 --- a/src/features/editScene/EditorApp.hpp +++ b/src/features/editScene/EditorApp.hpp @@ -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); diff --git a/src/features/editScene/demos/demo-taxi/demo_main.cpp b/src/features/editScene/demos/demo-taxi/demo_main.cpp index 07d3de3..7b01dd7 100644 --- a/src/features/editScene/demos/demo-taxi/demo_main.cpp +++ b/src/features/editScene/demos/demo-taxi/demo_main.cpp @@ -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 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()) + return Ogre::Vector3::ZERO; + const auto &t = e.get(); + 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(); + 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(); + 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()) + return fail("arrival marker missing for " + door); + const auto &mt = marker.get(); + 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(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()) + return fail("inside NPC entity missing"); + const auto &t = npc.get(); + const Ogre::Vector3 p = + t.node ? t.node->_getDerivedPosition() : + t.position; + bool anchored = false; + interior.children([&](flecs::entity c) { + if (!c.has()) + return true; + const auto &r = c.get(); + 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(); + 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()) + 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()) + 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) diff --git a/src/features/editScene/systems/CharacterRegistry.cpp b/src/features/editScene/systems/CharacterRegistry.cpp index a19be03..0499c14 100644 --- a/src/features/editScene/systems/CharacterRegistry.cpp +++ b/src/features/editScene/systems/CharacterRegistry.cpp @@ -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()) { auto &t = inst.get_mut(); t.position = c->position; diff --git a/src/features/editScene/systems/GeneratedInteriorService.cpp b/src/features/editScene/systems/GeneratedInteriorService.cpp index 1856734..a293e7e 100644 --- a/src/features/editScene/systems/GeneratedInteriorService.cpp +++ b/src/features/editScene/systems/GeneratedInteriorService.cpp @@ -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; diff --git a/src/features/editScene/systems/InteriorNpcSystem.cpp b/src/features/editScene/systems/InteriorNpcSystem.cpp index 211b62b..9fff53d 100644 --- a/src/features/editScene/systems/InteriorNpcSystem.cpp +++ b/src/features/editScene/systems/InteriorNpcSystem.cpp @@ -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 diff --git a/src/features/editScene/systems/InteriorNpcSystem.hpp b/src/features/editScene/systems/InteriorNpcSystem.hpp index 8c8d863..816bb54 100644 --- a/src/features/editScene/systems/InteriorNpcSystem.hpp +++ b/src/features/editScene/systems/InteriorNpcSystem.hpp @@ -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 &spawned() const { return m_spawned; @@ -53,4 +64,5 @@ private: EditorApp *m_app; std::vector m_spawned; size_t m_eligible = 0; + size_t m_missing = 0; }; diff --git a/src/features/editScene/systems/TownGenSystem.cpp b/src/features/editScene/systems/TownGenSystem.cpp index a18e093..1b785a6 100644 --- a/src/features/editScene/systems/TownGenSystem.cpp +++ b/src/features/editScene/systems/TownGenSystem.cpp @@ -934,7 +934,34 @@ void TownGenSystem::update() if (terrain.is_alive() && terrain.has()) graph = &terrain.get().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(