Phase 6.5 checkpoint

This commit is contained in:
2026-10-26 11:03:11 +03:00
parent 9e2d9fce59
commit adc4c91a2c
61 changed files with 10261 additions and 450 deletions
+110 -11
View File
@@ -1,4 +1,5 @@
project(building-parts)
find_program(PYTHON3_EXECUTABLE NAMES python3 python REQUIRED)
set(PARTS_FILES pier.blend)
set(FURNITURE_FILES furniture.blend furniture-sofa.blend)
set(PARTS_OUTPUT_DIRS)
@@ -15,23 +16,121 @@ foreach(PARTS_FILE ${PARTS_FILES})
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${PARTS_FILE} ${CMAKE_SOURCE_DIR}/assets/blender/scripts/export_building_parts.py)
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
endforeach()
set(FURNITURE_BAKED_BLENDS)
set(FURNITURE_ATLAS_PARTS)
list(LENGTH FURNITURE_FILES FURNITURE_COUNT)
foreach(FURNITURE_FILE ${FURNITURE_FILES})
list(FIND FURNITURE_FILES ${FURNITURE_FILE} FURNITURE_INDEX)
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
set(BAKE_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.bake_success)
set(ATLAS_PART ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}-atlas.png)
add_custom_command(
OUTPUT ${BAKED_BLEND} ${ATLAS_PART} ${BAKE_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E rm -f ${BAKE_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E copy
${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
-Y -P ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
-- ${ATLAS_PART} ${FURNITURE_INDEX} ${FURNITURE_COUNT}
${BAKE_SUCCESS}
COMMAND ${CMAKE_COMMAND} -D FILE=${BAKE_SUCCESS}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE}
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
list(APPEND FURNITURE_BAKED_BLENDS ${BAKED_BLEND})
list(APPEND FURNITURE_ATLAS_PARTS ${ATLAS_PART})
endforeach()
# One shared runtime atlas/material for all furniture sources. Each source
# owns a deterministic vertical band, so rebuilds cannot collide or reorder.
set(FURNITURE_ATLAS_PNG
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.png)
set(FURNITURE_ATLAS_MATERIAL
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-atlas.material)
add_custom_command(
OUTPUT ${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
COMMAND ${CMAKE_COMMAND} -E rm -f
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
COMMAND ${BLENDER} --factory-startup -b -P
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py --
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL}
${FURNITURE_ATLAS_PARTS}
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_PNG}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
COMMAND ${CMAKE_COMMAND} -D FILE=${FURNITURE_ATLAS_MATERIAL}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${FURNITURE_ATLAS_PARTS}
${CMAKE_CURRENT_SOURCE_DIR}/merge_furniture_atlases.py)
set(FURNITURE_EXPORT_STAMPS)
foreach(FURNITURE_FILE ${FURNITURE_FILES})
get_filename_component(FILE_NAME ${FURNITURE_FILE} NAME_WE)
set(PARTS_OUTPUT_DIR ${CMAKE_BINARY_DIR}/resources/buildings/parts/${FILE_NAME})
set(BAKED_BLEND ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend)
set(EXPORT_SUCCESS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}.export_success)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
COMMAND ${CMAKE_COMMAND} -E copy ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FURNITURE_FILE} -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py
DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/${FURNITURE_FILE} ${CMAKE_CURRENT_SOURCE_DIR}/bake_furniture.py)
add_custom_command(
OUTPUT ${PARTS_OUTPUT_DIR}
OUTPUT ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E rm -rf ${PARTS_OUTPUT_DIR}
COMMAND ${CMAKE_COMMAND} -E rm -f ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -E make_directory ${PARTS_OUTPUT_DIR}
COMMAND ${BLENDER} -b ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend
COMMAND ${BLENDER} -b ${BAKED_BLEND}
-Y -P
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
-- ${PARTS_OUTPUT_DIR}
DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${FILE_NAME}_baked.blend ${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py)
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR})
-- ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS}
COMMAND ${CMAKE_COMMAND} -D FILE=${EXPORT_SUCCESS}
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_file_size.cmake
DEPENDS ${BAKED_BLEND}
${CMAKE_CURRENT_SOURCE_DIR}/export_furniture_parts.py
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
list(APPEND PARTS_OUTPUT_DIRS ${PARTS_OUTPUT_DIR} ${EXPORT_SUCCESS})
list(APPEND FURNITURE_EXPORT_STAMPS ${EXPORT_SUCCESS})
endforeach()
# Validate the shared-atlas contract after every source has exported. This
# catches embedded texture copies, UVs escaping the source band and shared
# Blender mesh datablocks changing UVs between furniture variants.
set(FURNITURE_VALIDATION
${CMAKE_CURRENT_BINARY_DIR}/furniture-export.validated)
add_custom_command(
OUTPUT ${FURNITURE_VALIDATION}
COMMAND ${CMAKE_COMMAND} -E rm -f ${FURNITURE_VALIDATION}
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py
--root ${CMAKE_BINARY_DIR}/resources/buildings/parts
--source furniture 0 ${FURNITURE_COUNT}
--source furniture-sofa 1 ${FURNITURE_COUNT}
COMMAND ${CMAKE_COMMAND} -E touch ${FURNITURE_VALIDATION}
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
${FURNITURE_ATLAS_MATERIAL}
${CMAKE_CURRENT_SOURCE_DIR}/validate_furniture_export.py)
list(APPEND PARTS_OUTPUT_DIRS ${FURNITURE_VALIDATION}
${FURNITURE_ATLAS_PNG} ${FURNITURE_ATLAS_MATERIAL})
add_custom_target(import_building_parts ALL DEPENDS ${PARTS_OUTPUT_DIRS})
# Phase 6.5.B: opt-in preview gallery (kept out of the default build
# because rendering every asset is slow). Renders each root with its
# pivot, physical/collision bounds, occupancy cells and POI markers,
# then writes an HTML index with the corresponding atlas UV rectangles.
set(FURNITURE_GALLERY_DIR
${CMAKE_BINARY_DIR}/resources/buildings/parts/gallery)
add_custom_target(furniture_gallery
COMMAND ${CMAKE_COMMAND} -E make_directory ${FURNITURE_GALLERY_DIR}
COMMAND ${BLENDER} -b
${CMAKE_CURRENT_BINARY_DIR}/furniture_baked.blend -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
${FURNITURE_GALLERY_DIR}
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture
COMMAND ${BLENDER} -b
${CMAKE_CURRENT_BINARY_DIR}/furniture-sofa_baked.blend -Y -P
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py --
${FURNITURE_GALLERY_DIR}
${CMAKE_BINARY_DIR}/resources/buildings/parts/furniture-sofa
COMMAND ${PYTHON3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py
--root ${FURNITURE_GALLERY_DIR}
DEPENDS ${FURNITURE_EXPORT_STAMPS} ${FURNITURE_ATLAS_PNG}
${FURNITURE_ATLAS_MATERIAL}
${CMAKE_CURRENT_SOURCE_DIR}/render_furniture_gallery.py
${CMAKE_CURRENT_SOURCE_DIR}/generate_furniture_gallery_index.py)
@@ -0,0 +1,232 @@
# Furniture modeling and Blender export
This document describes the current furniture authoring path. The remaining
room-composition, DSL and Lua work is tracked in
`src/features/editScene/demos/demo-taxi/PLAN.md`, Phase 6.5.
Blender is the fast path for reusable furniture assets. Keep geometry, UVs,
the placement pivot, physical bounds and asset-local interaction markers with
the asset. Do not turn room layouts or game behavior into a large collection
of Blender custom properties and empties: reusable room arrangements belong to
the shared layout evaluator/DSL, while unique or behavior-heavy interiors use
prefab ECS components and Lua.
## Pipeline files
- `edit-firniture-props.py` (historical file-name typo) provides the Blender
**Furniture Controls** and **Action Controls** panels.
- `bake_furniture.py` bakes one source blend into a deterministic vertical
atlas band and assigns `M_Furniture_Atlas` to its visual meshes.
- `merge_furniture_atlases.py` stacks those bands into the shared
`furniture-atlas.png` and writes `furniture-atlas.material`.
- `export_furniture_parts.py` exports every root object named `furniture-*`
as GLB and writes a schema-v1 furniture-definition JSON containing bounds,
layer, placement rules, source hash and interaction markers.
- `validate_furniture_export.py` checks the generated GLB/JSON artifacts:
schema references, shared material, no embedded images, UVs inside the
source's atlas band, and identical UV bounds across variants sharing a
Blender mesh datablock.
- `CMakeLists.txt` runs baking, atlas merging, exporting and validation with
success
markers, so a Blender Python exception cannot leave stale output looking
current. `FURNITURE_FILES` currently lists `furniture.blend` and
`furniture-sofa.blend`.
For `furniture.blend`, furniture output goes to
`build*/resources/buildings/parts/furniture/`; `furniture-sofa.blend` uses
`resources/buildings/parts/furniture-sofa/`. The shared atlas and material
are written once to `resources/buildings/parts/`:
```text
furniture-<source-id>.glb
furniture-<source-id>.glb.json
resources/buildings/parts/furniture-atlas.png
resources/buildings/parts/furniture-atlas.material
```
The GLB keeps the `M_Furniture_Atlas` material name but deliberately embeds
no image; OGRE loads the shared material from `furniture-atlas.material`.
JSON files are generated output: edit the `.blend` source and re-export
instead of hand-editing build artifacts.
## Author a furniture root
1. Open the source blend and run `edit-firniture-props.py` from Blender's
Text Editor to register the helper panels.
2. Create a root **Empty** named `furniture-<id>`. Parent all visual meshes
to it. Keep the root origin at the intended placement pivot and use the
same forward orientation as the existing assets.
3. Select the root and use **Furniture Controls -> Create Furniture**.
Available asset-local properties are:
- `furniture_id` — stable definition id; defaults to the root suffix.
It survives a cosmetic Blender object rename. Legacy roots named
`furniture--<id>` export as `<id>-legacy` with the old name kept as an
alias.
- `display_name` — optional UI-facing name.
- `furniture_tags` — comma-separated selection tags such as
`bedroom, essential` or `livingroom, filler`.
- `layer` — `normal`, `thin-floor`, `thin-wall` or `ceiling`.
- `selection_weight` — non-negative weighted-random factor.
- `requires_floor` / `requires_wall` — placement constraints.
- `clearance` — extra occupied-footprint margin in metres.
- `footprint_x` / `footprint_z` — optional occupancy override in grid
cells; leave both at 0 to derive occupancy from mesh bounds at the
target grid's cell size.
- `collision_mesh` — optional explicit mesh object to use as simplified
collision bounds. Meshes named `collision-*` are also treated as
collision-only and are not exported as render geometry.
4. Keep the asset reusable. A chair, table, counter section, shelf or
repair-bay fixture is an asset; where several such assets belong in a
particular room is a layout concern, not root-object metadata.
The exporter computes `bounds` from the visual mesh hierarchy in root-local
space. If collision meshes exist, it also emits `collisionBounds`. Existing
`foo-col` mesh names are **not** assumed to be collision because some legacy
assets use that suffix for visible geometry; use `collision-*` or the explicit
`collision_mesh` property for new collision proxies.
Runtime layer conventions: normal items use `collisionBounds` when present,
otherwise physical `bounds`, for their generated collider; thin/ceiling
layers never collide or obstruct the navmesh. A thin-wall root's origin is
its attachment point and its quarter-turn placement selects the wall plane
(internal inset 0.1 m, external 0.2 m outside the cell). A ceiling root's
origin is the ceiling attachment point (place geometry below it, e.g. the
ceiling light's negative local Z in Blender).
## Add local interaction markers
Simple asset-local interactions stay in Blender:
1. Add an **Empty** named `action-<name>` as a **direct child** of the
furniture root. The helper offers Use and Sit actions and initializes
`name`, `action`, `action_text`, `height`, `radius` and `tags`.
2. Position/orient the action empty where the interaction occurs. Set its
action name and interaction dimensions in **Action Controls**.
3. Add optional `position-<name>` empties below that action for named
approach/enter/exit transforms. The helper can create enter/exit markers.
Root-level `position-*` children are also exported.
4. The exporter additionally preserves action custom properties prefixed
`goap_prereq_` and `goap_effect_`.
Only direct root/action children are discovered by the exporter, so preserve
that hierarchy. At runtime `CellGridSystem` turns exported action markers
into grid-owned entities carrying both `ActuatorComponent` and
`SmartObjectComponent`; nested/root position markers become named runtime
transforms without an actuator. Every marker has a stable id
`furniture.<placementId>.<markerName>` and can be found through
`CellGridSystem::queryFurniturePois` / `findFurniturePoi` by room,
furniture tag, action, marker type or grid. Use these markers for local facts such as "sit here" or
"stand at this side of the counter". Store work rules, repair behavior,
cross-entity references and room-dependent relationships belong to the
ECS/Lua layer instead.
## Bake and export
Add a new source blend to `FURNITURE_FILES` in this directory's
`CMakeLists.txt`, then run:
```bash
cmake --build build-vscode --target import_building_parts -j4
```
The build performs these steps:
1. Copy the source blend to the build tree.
2. Bake its visual meshes into that source's deterministic atlas band.
3. Remap its UVs into that band.
4. Merge all source bands into one shared atlas and material.
5. Export GLBs without embedded texture copies, then write schema-v1 JSON.
6. Validate the GLB/JSON set, including UV bands and shared mesh datablock
variants.
7. Check per-step success marker files so failed Blender scripts fail the
build instead of leaving stale artifacts.
`src/features/editScene/resources.cfg` indexes
`resources/buildings/parts`, `resources/buildings/parts/furniture` and
`resources/buildings/parts/furniture-sofa`, so the shared material and both
current definition sets are loadable. At runtime `FurnitureLibrary`
discovers `furniture-*.json`, validates schema/identity/bounds and exposes
stable ids and aliases.
To add the phase 6.5 layer fixtures to the existing source (idempotent),
then export and render the preview gallery:
```bash
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
cmake --build build-vscode --target import_building_parts -j2
cmake --build build-vscode --target furniture_gallery -j2
```
The gallery is written to `resources/buildings/parts/gallery/index.html`:
each asset is rendered at its export pivot with physical bounds (green),
collision bounds (red), occupancy cells (blue), action/position markers
and an atlas UV rectangle. `render_furniture_gallery.py` and
`generate_furniture_gallery_index.py` are the generators. The gallery
target is opt-in so routine builds do not render every asset.
## Choosing the authoring layer
| Need | Preferred home |
| --- | --- |
| Mesh, UVs, atlas, pivot and physical bounds | Blender |
| Optional simplified collision mesh | Blender (`collision-*` or `collision_mesh`) |
| Simple local sit/use/action and approach markers | Blender |
| Single-mesh or multi-cell placement | Furniture definition + shared C++ kernel |
| Multi-entity ECS behavior or custom components | Editor prefab, reusing the Blender asset |
| Prefab-backed furniture | `prefabs/furniture.<name>.json` plus a definition whose `prefab` points at it; Create Prefab's **Furniture prefab** mode generates the companion definition with tags, layer, measured bounds and a deterministic prefab source hash |
| Reusable dining sets, counter/shelf rows, room-fit rules | Shared room-composition evaluator and layout DSL (Phase 6.5.F) |
| Unique store/garage layouts, scripted actions, exterior-entry wiring | Lua plus normal door/actuator/vehicle APIs (Phase 6.5.H) |
| Occupancy, navigation, identity and lifecycle invariants | Shared C++ furniture kernel |
A table with chairs around it should not require a bespoke Blender empty
hierarchy: place the table at a room anchor and generate the chairs relative
to it through the shared composition evaluator. Likewise, a store or garage
uses Blender-built counters, shelves and fixtures, but its working behavior
and exterior-entry setup should remain readable DSL/Lua/ECS data.
Prefab instances placed through furniture definitions are runtime children of
the CellGrid. They are not editor-selectable scene entities; non-normal
layers strip physics components from the instantiated subtree, while normal
prefabs keep their authored colliders.
## Layout DSL metadata bridge
Room declarations can record furniture-selection tags without hardcoding a
placement algorithm:
```lisp
(room shop (rect 0 0 1 1)
:type store
:furniture (shop|market counter !display))
```
Entries in the `:furniture` list are ANDed. `a|b` accepts either tag and
`!a` rejects a tag. The same term syntax works in RoomComponent tag lists.
`BuildingPlan::roomFurnitureTags` stores the list parallel to
`roomNames`/`roomTypes`, and the road geometry test suite covers the round
trip through layout evaluation.
Full/interiorOnly grids materialize those plan rooms as RoomComponents
(`TownGenSystem::createPlanRooms`), so the same RoomLayoutSystem furnishes
DSL-authored and node-authored rooms. Reusable arrangements use the
`:compose` room option instead of bespoke Blender hierarchies:
```lisp
:compose (
(group (livingroom nowall) :at center
(child (lounge filler) -2 -2 0)
(child (lounge filler) 2 -2 0)
(child (lounge filler) -2 2 0)
(child (lounge filler) 2 2 0))
(row (nowindow optional) :at zpos :step 2 :count 2))
```
A `group` is one root plus children at cell offsets (rotated with the
root); the whole group fits or rolls back atomically. A `row` walks a
wall edge with `step`/`skip`/`count` and skips blocked cells. Anchor is
`center` or `zneg`/`zpos`/`xneg`/`xpos`; `:rot 0..3` overrides the root
rotation (default: edge anchors face into the room). Tags are selection
terms, not definition ids — check the exported `tags` array. The same
rules are serialized on RoomComponent and editable in its Furniture
Compositions panel.
@@ -0,0 +1,132 @@
"""Add the phase 6.5 layer fixtures to furniture.blend (idempotent).
Run from the project root:
../../blender-bin/bin/blender -b assets/blender/buildings/parts/furniture.blend \
-Y -P assets/blender/buildings/parts/add_furniture_fixtures.py
The regular import_building_parts target then bakes/exports them. No
room-placement rules live here: layers, bounds and (for the shelf) a
simplified collision proxy are the only asset-local metadata.
"""
import bpy
def material(name, color):
existing = bpy.data.materials.get(name)
if existing:
return existing
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = 1.0
return mat
def make_root(name, tags, layer, requires_floor, requires_wall):
existing = bpy.data.objects.get(name)
if existing:
return existing, False
root = bpy.data.objects.new(name, None)
root.empty_display_type = 'PLAIN_AXES'
root.empty_display_size = 0.15
bpy.context.scene.collection.objects.link(root)
root["furniture_id"] = name.replace("furniture-", "")
root["display_name"] = root["furniture_id"]
root["furniture_tags"] = tags
root["layer"] = layer
root["selection_weight"] = 1.0
root["requires_floor"] = requires_floor
root["requires_wall"] = requires_wall
root["clearance"] = 0.0
root["footprint_x"] = 0
root["footprint_z"] = 0
return root, True
def box(root, name, location, dimensions, mat, collision=False):
bpy.ops.mesh.primitive_cube_add(size=1.0)
obj = bpy.context.object
obj.name = name
obj.dimensions = dimensions
bpy.ops.object.transform_apply(location=False, rotation=False,
scale=True)
obj.parent = root
obj.location = location
if mat and not collision:
obj.data.materials.append(mat)
if collision:
obj.display_type = 'WIRE'
return obj
def ensure_attachment(root):
"""A root-local marker used by the runtime layer-transform checks."""
if any(child.name == "position-attach" for child in root.children):
return
marker = bpy.data.objects.new("position-attach", None)
marker.empty_display_size = 0.05
bpy.context.scene.collection.objects.link(marker)
marker.parent = root
marker.location = (0, 0, 0)
marker["name"] = "attach"
marker["type"] = "attachment"
fabric = material("FixtureFabric", (0.34, 0.08, 0.08))
trim = material("FixtureTrim", (0.12, 0.07, 0.04))
plastic = material("FixturePlastic", (0.82, 0.82, 0.78))
accent = material("FixtureAccent", (0.08, 0.25, 0.65))
picture = material("FixturePicture", (0.15, 0.45, 0.28))
light = material("FixtureLight", (1.0, 0.87, 0.55))
wood = material("FixtureWood", (0.42, 0.25, 0.12))
root, created = make_root("furniture-carpet1",
"livingroom, bedroom, filler", "thin-floor",
True, False)
if created:
box(root, "CarpetBody", (0, 0, 0.01), (1.6, 1.2, 0.02), fabric)
box(root, "CarpetInset", (0, 0, 0.021), (1.35, 0.95, 0.004), trim)
ensure_attachment(root)
root, created = make_root("furniture-switch1", "office, utility, optional",
"thin-wall", False, True)
if created:
box(root, "SwitchPlate", (0, 0, 0), (0.12, 0.04, 0.18), plastic)
box(root, "SwitchToggle", (0, 0.025, 0), (0.035, 0.02, 0.08), trim)
ensure_attachment(root)
root, created = make_root("furniture-picture1", "livingroom, office, optional",
"thin-wall", False, True)
if created:
box(root, "PictureFrame", (0, 0, 0), (0.8, 0.05, 0.55), trim)
box(root, "PictureCanvas", (0, 0.03, 0), (0.68, 0.012, 0.43), picture)
box(root, "PictureMark", (0.12, 0.038, 0.02),
(0.25, 0.006, 0.18), accent)
root, created = make_root("furniture-ceiling-light1",
"livingroom, office, essential", "ceiling",
False, False)
if created:
box(root, "CeilingLightHousing", (0, 0, -0.06), (0.6, 0.6, 0.12),
plastic)
box(root, "CeilingLightDiffuser", (0, 0, -0.13),
(0.45, 0.45, 0.03), light)
ensure_attachment(root)
root, created = make_root("furniture-shop-shelf1", "shop, display, important",
"normal", True, False)
if created:
box(root, "ShelfBase", (0, 0, 0.05), (1.5, 0.4, 0.1), wood)
box(root, "ShelfBack", (0, -0.17, 0.75), (1.5, 0.06, 1.5), wood)
for x in (-0.72, 0.72):
box(root, "ShelfSide", (x, 0, 0.75), (0.06, 0.4, 1.5), wood)
for z in (0.5, 1.0, 1.5):
box(root, "ShelfBoard", (0, 0, z), (1.5, 0.4, 0.05), wood)
collision = box(root, "collision-shelf", (0, 0, 0.75),
(1.5, 0.4, 1.5), None, collision=True)
root["collision_mesh"] = collision.name
bpy.ops.wm.save_as_mainfile(filepath=bpy.data.filepath)
print("Phase 6.5 furniture fixtures present.")
@@ -1,9 +1,27 @@
import bpy
import os
import sys
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ATLAS_OUTPUT = argv[0] if argv else ""
ATLAS_INDEX = int(argv[1]) if len(argv) > 1 else 0
ATLAS_COUNT = int(argv[2]) if len(argv) > 2 else 1
SUCCESS_OUTPUT = argv[3] if len(argv) > 3 else ""
if ATLAS_INDEX < 0 or ATLAS_INDEX >= ATLAS_COUNT:
raise RuntimeError("invalid furniture atlas band index")
def is_collision_name(name):
# Existing assets also use "-col" for some VISUAL meshes, so only the
# explicit collision-* prefix is excluded automatically.
return name.lower().split(".", 1)[0].startswith("collision-")
def get_all_children_recursive(obj, mesh_list):
"""Recursively finds all mesh objects in the hierarchy."""
"""Recursively finds visual mesh objects in the hierarchy."""
for child in obj.children:
if child.type == 'MESH' and child.data.uv_layers:
if child.type == 'MESH' and child.data.uv_layers and \
not is_collision_name(child.name):
mesh_list.append(child)
get_all_children_recursive(child, mesh_list)
@@ -11,7 +29,8 @@ def setup_furniture_atlas():
# 1. Gather valid objects: Children of empties starting with "furniture-"
furniture_meshes = []
roots = [o for o in bpy.data.objects if o.name.startswith("furniture-")]
roots = [o for o in bpy.context.scene.objects
if o.parent is None and o.name.startswith("furniture-")]
for root in roots:
# If the root itself is a mesh with UVs, add it
@@ -20,18 +39,15 @@ def setup_furniture_atlas():
# Find all nested children
get_all_children_recursive(root, furniture_meshes)
# Remove duplicates if any (in case of complex parenting)
furniture_meshes = list(set(furniture_meshes))
# Remove duplicates while preserving scene order.
seen = set()
furniture_meshes = [obj for obj in furniture_meshes
if not (obj.name in seen or seen.add(obj.name))]
if not furniture_meshes:
print("No recursive mesh objects found with UVs.")
return
raise RuntimeError("No visual furniture mesh objects found with UVs.")
furniture_objs = furniture_meshes
if not furniture_objs:
print("No valid furniture mesh objects found with UVs.")
return
# 2. Manage the Atlas Image (FurnitureColor)
image_name = "FurnitureColor"
@@ -74,7 +90,10 @@ def setup_furniture_atlas():
scene.render.bake.use_pass_indirect = False
scene.render.bake.use_pass_color = True
scene.render.bake.target = 'IMAGE_TEXTURES'
# Empty atlas space stays transparent; the merge step stacks the
# per-source bakes into deterministic vertical bands.
scene.render.film_transparent = True
print("Baking Furniture Atlas...")
override = {
'active_object': bpy.context.view_layer.objects.active,
@@ -83,6 +102,13 @@ def setup_furniture_atlas():
with bpy.context.temp_override(**override):
bpy.ops.object.bake(type='DIFFUSE', margin=2)
if ATLAS_OUTPUT:
os.makedirs(os.path.dirname(ATLAS_OUTPUT), exist_ok=True)
atlas_img.filepath_raw = ATLAS_OUTPUT
atlas_img.file_format = 'PNG'
atlas_img.save()
print("Partial atlas saved to: " + ATLAS_OUTPUT)
atlas_img.pack()
# 6. Create and Assign Final Atlas Material
@@ -106,7 +132,31 @@ def setup_furniture_atlas():
obj.data.materials.clear()
obj.data.materials.append(atlas_mat)
print("Process Complete. Atlas material applied.")
# Put this source file into its deterministic vertical atlas band.
# The merge script stacks the per-source bakes in the same order.
if ATLAS_COUNT > 1:
remapped_mesh_data = set()
for obj in furniture_objs:
# Variants such as table1/table2 and pole1/pole2 share mesh
# datablocks. UV layers belong to the mesh data, so remap each
# datablock once; otherwise the band transform compounds.
if obj.data.name in remapped_mesh_data:
continue
remapped_mesh_data.add(obj.data.name)
for uv_layer in obj.data.uv_layers:
for loop in uv_layer.data:
u, v = loop.uv
if u < -0.001 or u > 1.001 or \
v < -0.001 or v > 1.001:
raise RuntimeError(
"Furniture UV escapes the source atlas on " +
obj.name)
loop.uv = (min(max(u, 0.0), 1.0),
(min(max(v, 0.0), 1.0) + ATLAS_INDEX) /
ATLAS_COUNT)
print("Process Complete. Atlas material applied; UV band %d/%d." %
(ATLAS_INDEX + 1, ATLAS_COUNT))
for mat in bpy.data.materials:
if mat.users == 0:
bpy.data.materials.remove(mat)
@@ -118,8 +168,11 @@ def setup_furniture_atlas():
new_path = current_path.replace(".blend", "_baked.blend")
bpy.ops.wm.save_as_mainfile(filepath=new_path)
print(f"File saved to: {new_path}")
if SUCCESS_OUTPUT:
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
marker.write("ok\n")
else:
print("Warning: File not saved (unsaved blend file).")
raise RuntimeError("Furniture blend was not saved")
setup_furniture_atlas()
@@ -0,0 +1,10 @@
if(EXISTS "${FILE}")
file(SIZE "${FILE}" FILE_SIZE)
if(FILE_SIZE GREATER 0)
message(STATUS "Build-time check: ${FILE} exists and is not empty.")
else()
message(FATAL_ERROR "Build-time check: ${FILE} exists but is empty!")
endif()
else()
message(FATAL_ERROR "Build-time check: ${FILE} does not exist!")
endif()
@@ -2,7 +2,22 @@ import bpy
import mathutils
ACTION_PROPS = ["name", "action", "action_text", "height", "radius", "tags"]
FURNITURE_PROPS = ["name", "furniture_tags"]
# Asset-local furniture metadata exported to furniture-*.json. Room
# placement/composition deliberately stays out of these properties; the
# layout DSL/Lua handles table sets, stores, garages and similar setups.
FURNITURE_PROPS = [
"furniture_id",
"display_name",
"furniture_tags",
"layer",
"selection_weight",
"requires_floor",
"requires_wall",
"clearance",
"footprint_x",
"footprint_z",
"collision_mesh",
]
test_tracking = {"lib_name": None}
@@ -122,23 +137,26 @@ class OBJECT_OT_CreateFurnitureProps(bpy.types.Operator):
def execute(self, context):
obj = context.object
defaults = {
"furniture_id": obj.name.replace("furniture-", "").strip("-"),
"display_name": obj.name.replace("furniture-", "").replace("-", " ").title(),
"furniture_tags": "",
"layer": "normal",
"selection_weight": 1.0,
"requires_floor": True,
"requires_wall": False,
"clearance": 0.0,
"footprint_x": 0, # 0 = derive from mesh bounds
"footprint_z": 0,
"collision_mesh": "", # optional collision-* mesh name
}
# Older assets only had the now-obsolete custom "name" property;
# use it once as the stable id instead of guessing tags from names.
if "furniture_id" not in obj and "name" in obj:
defaults["furniture_id"] = str(obj["name"]).strip("-")
for p in FURNITURE_PROPS:
if p not in obj:
if p == "name":
obj[p] = obj.name.replace("furniture-","")
elif p == "furniture_tags":
obj[p] = ""
otags = []
if obj.name.find("toilet") >= 0:
otags.append("wc")
otags.append("bathroom")
otags.append("toilet")
elif obj.name.find("bed") >= 0:
otags.append("bed")
otags.append("bedroom")
obj[p] = ", ".join(otags)
else:
obj[p] = "" # Initialize as empty text string
obj[p] = defaults[p]
return {'FINISHED'}
class OBJECT_OT_CreateChildEmpties(bpy.types.Operator):
@@ -324,7 +342,7 @@ class OBJECT_PT_FurnitureEmptyPanel(bpy.types.Panel):
else:
# Show the text fields if properties exist
box = layout.box()
box.label(text="Furnitore Settings", icon='PROPERTIES')
box.label(text="Furniture Settings", icon='PROPERTIES')
for p in FURNITURE_PROPS:
box.prop(obj, f'["{p}"]', text=p.replace("_", " ").title())
op_use = layout.operator("object.create_child_action_node", icon='EMPTY_DATA', text="Add Use Action Node")
@@ -346,8 +364,8 @@ def register():
def unregister():
bpy.utils.register_class(OBJECT_OT_CreateActionProps)
bpy.utils.register_class(OBJECT_OT_CreateFurnitureProps)
bpy.utils.unregister_class(OBJECT_OT_CreateActionProps)
bpy.utils.unregister_class(OBJECT_OT_CreateFurnitureProps)
bpy.utils.unregister_class(OBJECT_PT_ActionEmptyPanel)
bpy.utils.unregister_class(OBJECT_OT_CreateChildEmpties)
bpy.utils.unregister_class(OBJECT_OT_DestroyChildByProp)
@@ -1,14 +1,16 @@
import bpy
import os, sys, time
import json
from mathutils import Matrix, Quaternion
import os
import re
import sys
import bpy
from mathutils import Matrix, Quaternion, Vector
argv = sys.argv
argv = argv[argv.index("--") + 1:]
incpath = os.path.dirname(__file__)
sys.path.insert(0, incpath)
outdir = argv[0]
SUCCESS_OUTPUT = argv[1] if len(argv) > 1 else ""
basis_change = Matrix((
(1, 0, 0, 0),
@@ -17,180 +19,317 @@ basis_change = Matrix((
(0, 0, 0, 1)
))
def export_root_objects_to_gltf(output_dir):
# Ensure the output directory exists
if not os.path.exists(output_dir):
os.makedirs(output_dir)
LAYERS = {"normal", "thin-floor", "thin-wall", "ceiling"}
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
# Deselect all objects
bpy.ops.object.select_all(action='DESELECT')
# Iterate through all objects in the scene
for obj in bpy.context.scene.objects:
# Check if the object has no parent and has children
# The original request specifies "objects having no parent with children".
# This condition captures those that are explicitly root objects of a hierarchy.
if obj.parent is None:
# Select the root object and all its children
def round4(value):
return round(float(value), 4)
print(obj.name)
if not obj.name.startswith("furniture-"):
continue
desc = {}
desc["name"] = obj.name.replace("furniture-","")
desc["mesh"] = obj.name + ".glb"
if "furniture_tags" in obj:
mtags = obj["furniture_tags"]
if "," in mtags:
atags = [m.strip() for m in mtags.split(",")]
desc["tags"] = atags
else:
desc["tags"] = [mtags]
def prop(root, name, default):
return root[name] if name in root else default
def stable_id(root):
custom = str(prop(root, "furniture_id", "")).strip().lower()
legacy_suffix = root.name.replace("furniture-", "")
raw = custom or legacy_suffix.strip("-").lower()
if not custom and legacy_suffix.startswith("-"):
# Keep the accidental furniture--<name> asset addressable without
# colliding with the canonical furniture-<name> root.
raw += "-legacy"
if not raw or not ID_PATTERN.match(raw):
raise ValueError("invalid furniture id %r on %s" % (raw, root.name))
return raw
def parse_tags(value):
# Existing blends use comma-separated custom-property strings. Also
# accept semicolons and the legacy "tag. tag" typo, normalize case and
# drop empty/duplicate entries while preserving order.
result = []
seen = set()
for part in re.split(r"[,;]|\.\s+", str(value)):
tag = part.strip().lower()
if not tag:
continue
if not re.match(r"^[a-z0-9][a-z0-9._-]*$", tag):
raise ValueError("invalid furniture tag %r" % tag)
if tag not in seen:
seen.add(tag)
result.append(tag)
return result
def iter_descendants(root):
stack = list(root.children)
while stack:
obj = stack.pop(0)
yield obj
stack.extend(obj.children)
def is_collision_object(obj):
# Existing assets also use "-col" for some VISUAL meshes, so only the
# explicit collision-* prefix is excluded automatically.
return obj.name.lower().split(".", 1)[0].startswith("collision-")
def is_visual_mesh(obj):
return obj.type == 'MESH' and not is_collision_object(obj)
def bounds_in_root_space(root, objects):
root_inv = root.matrix_world.inverted()
minimum = None
maximum = None
for obj in objects:
if len(obj.bound_box) == 0:
continue
matrix = root_inv @ obj.matrix_world
for corner in obj.bound_box:
local = basis_change @ (matrix @ Vector(corner))
if minimum is None:
minimum = local.copy()
maximum = local.copy()
else:
desc["tags"] = []
desc["sensors"] = []
desc["actions"] = []
desc["positions"] = []
for child in obj.children:
if child.name.startswith("action-"):
if not "action" in child:
continue
if not "height" in child:
continue
if not "radius" in child:
continue
local_matrix = child.matrix_local
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos_d = ogre_local_matrix.to_translation()
local_rot_d = ogre_local_matrix.to_quaternion()
local_pos = [round(x, 4) for x in local_pos_d]
local_rot = [round(x, 4) for x in local_rot_d]
action = {}
action["position_x"] = local_pos[0]
action["position_y"] = local_pos[1]
action["position_z"] = local_pos[2]
action["rotation_w"] = local_rot[0]
action["rotation_x"] = local_rot[1]
action["rotation_y"] = local_rot[2]
action["rotation_z"] = local_rot[3]
action["height"] = child["height"]
action["radius"] = child["radius"]
action["action"] = child["action"]
if "action_text" in child:
action["action_text"] = child["action_text"]
else:
action["action_text"] = child["action"].capitalize()
if "name" in child:
action["name"] = child["name"]
else:
action["name"] = desc["name"] + "_" + str(len(desc["actions"]))
if "tags" in child:
atags = child["tags"]
if "," in atags:
atags = [m.strip() for m in atags.split(",")]
action["tags"] = atags
else:
action["tags"] = [atags]
else:
action["tags"] = []
for k, v in child.items():
if k.startswith("goap_prereq_"):
action[k] = v
elif k.startswith("goap_effect_"):
action[k] = v
action["furniture"] = {}
action["furniture"]["name"] = desc["name"]
action["furniture"]["tags"] = desc["tags"]
action["positions"] = []
for schild in child.children:
if schild.name.startswith("position-"):
local_matrix = schild.matrix_local
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos_d = ogre_local_matrix.to_translation()
local_rot_d = ogre_local_matrix.to_quaternion()
local_pos = [round(x, 4) for x in local_pos_d]
local_rot = [round(x, 4) for x in local_rot_d]
position = {}
if "name" in schild:
position["name"] = schild["name"]
if "type" in schild:
position["type"] = schild["type"]
position["position_x"] = local_pos[0]
position["position_y"] = local_pos[1]
position["position_z"] = local_pos[2]
position["rotation_w"] = local_rot[0]
position["rotation_x"] = local_rot[1]
position["rotation_y"] = local_rot[2]
position["rotation_z"] = local_rot[3]
action["positions"].append(position)
desc["actions"].append(action)
if child.name.startswith("position-"):
local_matrix = child.matrix_local
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos_d = ogre_local_matrix.to_translation()
local_rot_d = ogre_local_matrix.to_quaternion()
local_pos = [round(x, 4) for x in local_pos_d]
local_rot = [round(x, 4) for x in local_rot_d]
position = {}
if "name" in child:
position["name"] = child["name"]
if "type" in child:
position["type"] = child["type"]
position["position_x"] = local_pos[0]
position["position_y"] = local_pos[1]
position["position_z"] = local_pos[2]
position["rotation_w"] = local_rot[0]
position["rotation_x"] = local_rot[1]
position["rotation_y"] = local_rot[2]
position["rotation_z"] = local_rot[3]
desc["positions"].append(position)
minimum.x = min(minimum.x, local.x)
minimum.y = min(minimum.y, local.y)
minimum.z = min(minimum.z, local.z)
maximum.x = max(maximum.x, local.x)
maximum.y = max(maximum.y, local.y)
maximum.z = max(maximum.z, local.z)
if minimum is None:
return None
return ([round4(minimum.x), round4(minimum.y), round4(minimum.z)],
[round4(maximum.x), round4(maximum.y), round4(maximum.z)])
obj.select_set(True)
for child in obj.children_recursive:
child.select_set(True)
obj.location = (0.0, 0.0, 0.0)
# Set the root object as the active object for the export operator
bpy.context.view_layer.objects.active = obj
def transform_json(root, obj):
local_matrix = root.matrix_world.inverted() @ obj.matrix_world
ogre_local_matrix = basis_change @ local_matrix @ basis_change.inverted()
local_pos = ogre_local_matrix.to_translation()
local_rot = ogre_local_matrix.to_quaternion()
return [round4(x) for x in local_pos], [round4(x) for x in local_rot]
# Define the output file path
file_path = os.path.join(output_dir, f"{obj.name}.glb")
# Export the selected objects to a glTF file
bpy.ops.export_scene.gltf(filepath=file_path,
use_selection=True,
check_existing=False,
export_format='GLB',
export_texture_dir='textures', export_texcoords=True,
export_normals=True,
export_tangents=True,
export_materials='EXPORT',
export_colors=True,
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_renderable=False,
export_yup=True,
export_apply=True,
export_animations=True,
export_force_sampling=True,
export_def_bones=False,
export_current_frame=False,
export_morph=True,
export_morph_animation=False,
export_morph_normal=True,
export_morph_tangent=True,
export_lights=False,
export_skins=True)
# Deselect all objects for the next iteration
bpy.ops.object.select_all(action='DESELECT')
print(f"Exported {obj.name} and its children to {file_path}")
with open(file_path + ".json", "w") as json_data:
json.dump(desc, json_data)
def position_json(root, child):
pos, rot = transform_json(root, child)
position = {}
if "name" in child:
position["name"] = str(child["name"])
if "type" in child:
position["type"] = str(child["type"])
position["position_x"] = pos[0]
position["position_y"] = pos[1]
position["position_z"] = pos[2]
position["rotation_w"] = rot[0]
position["rotation_x"] = rot[1]
position["rotation_y"] = rot[2]
position["rotation_z"] = rot[3]
return position
def action_json(root, child, furniture_id, furniture_tags):
for required in ("action", "height", "radius"):
if required not in child:
raise ValueError("action %s on %s lacks %s" %
(child.name, root.name, required))
pos, rot = transform_json(root, child)
height = float(child["height"])
radius = float(child["radius"])
if height <= 0.0 or radius <= 0.0:
raise ValueError("action %s on %s has non-positive dimensions" %
(child.name, root.name))
action = {}
action["position_x"] = pos[0]
action["position_y"] = pos[1]
action["position_z"] = pos[2]
action["rotation_w"] = rot[0]
action["rotation_x"] = rot[1]
action["rotation_y"] = rot[2]
action["rotation_z"] = rot[3]
action["height"] = height
action["radius"] = radius
action["action"] = str(child["action"])
action["action_text"] = str(child.get("action_text",
action["action"].capitalize()))
action["name"] = str(child.get("name",
furniture_id + "_" + str(len(root.children))))
action["tags"] = parse_tags(child.get("tags", ""))
for key, value in child.items():
if key.startswith("goap_prereq_") or key.startswith("goap_effect_"):
action[key] = value
action["furniture"] = {"name": furniture_id, "tags": furniture_tags}
action["positions"] = []
for position in child.children:
if position.name.startswith("position-"):
action["positions"].append(position_json(child, position))
return action
def content_hash(desc):
payload = json.dumps(desc, sort_keys=True, separators=(",", ":"),
ensure_ascii=False)
value = 14695981039346656037
for byte in payload.encode("utf-8"):
value ^= byte
value = (value * 1099511628211) & 0xffffffffffffffff
return "%016x" % value
def export_root_objects_to_gltf(output_dir):
os.makedirs(output_dir, exist_ok=True)
exported = []
for obj in bpy.context.scene.objects:
if obj.parent is not None or not obj.name.startswith("furniture-"):
continue
print(obj.name)
furniture_id = stable_id(obj)
tags = parse_tags(prop(obj, "furniture_tags", ""))
layer = str(prop(obj, "layer", "normal")).strip().lower()
if layer not in LAYERS:
raise ValueError("%s has unknown layer %r" % (obj.name, layer))
selection_weight = float(prop(obj, "selection_weight", 1.0))
clearance = float(prop(obj, "clearance", 0.0))
if selection_weight < 0.0 or clearance < 0.0:
raise ValueError("%s has negative weight/clearance" % obj.name)
footprint_x = int(prop(obj, "footprint_x", 0))
footprint_z = int(prop(obj, "footprint_z", 0))
if (footprint_x == 0) != (footprint_z == 0):
raise ValueError("%s needs both footprint axes or neither" %
obj.name)
if footprint_x < 0 or footprint_z < 0:
raise ValueError("%s has a negative footprint" % obj.name)
descendants = list(iter_descendants(obj))
visual_meshes = [child for child in descendants if is_visual_mesh(child)]
if not visual_meshes:
raise ValueError("%s has no visual mesh children" % obj.name)
collision_name = str(prop(obj, "collision_mesh", "")).strip()
collision_meshes = [child for child in descendants
if child.type == 'MESH' and
(is_collision_object(child) or
(collision_name and child.name == collision_name))]
if collision_name and not collision_meshes:
raise ValueError("%s references missing collision mesh %s" %
(obj.name, collision_name))
desc = {
"schemaVersion": 1,
"id": furniture_id,
# `name` is the legacy runtime identifier. Keep it equal to
# the stable id so old FurnitureCell records continue to work.
"name": furniture_id,
"displayName": str(prop(obj, "display_name", furniture_id)),
"mesh": obj.name + ".glb",
"materialName": "M_Furniture_Atlas",
"atlas": "furniture-atlas.png",
"source": "blender",
"layer": layer,
"tags": tags,
"anchor": [0.0, 0.0, 0.0],
"requiresFloor": bool(prop(obj, "requires_floor", True)),
"requiresWall": bool(prop(obj, "requires_wall", False)),
"clearance": clearance,
"weight": selection_weight,
"sensors": [],
"actions": [],
"positions": [],
}
legacy_suffix = obj.name.replace("furniture-", "")
aliases = []
if legacy_suffix != furniture_id:
aliases.append(legacy_suffix)
legacy_custom_name = str(obj.get("name", "")).strip()
if legacy_custom_name and legacy_custom_name != furniture_id and \
legacy_custom_name not in aliases:
aliases.append(legacy_custom_name)
if aliases:
desc["aliases"] = aliases
bounds = bounds_in_root_space(obj, visual_meshes)
if bounds:
desc["bounds"] = {"min": bounds[0], "max": bounds[1]}
collision_bounds = bounds_in_root_space(obj, collision_meshes)
if collision_bounds:
desc["collisionBounds"] = {"min": collision_bounds[0],
"max": collision_bounds[1]}
if footprint_x > 0:
desc["footprintCells"] = [footprint_x, footprint_z]
for child in obj.children:
if child.name.startswith("action-"):
desc["actions"].append(
action_json(obj, child, furniture_id, tags))
elif child.name.startswith("position-"):
desc["positions"].append(position_json(obj, child))
desc["sourceHash"] = content_hash(desc)
# Export only the visual hierarchy. action-/position- empties are
# metadata and collision-* meshes are physics bounds, not render
# content.
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
for child in descendants:
if child.name.startswith("action-") or \
child.name.startswith("position-") or \
is_collision_object(child):
continue
child.select_set(True)
obj.location = (0.0, 0.0, 0.0)
bpy.context.view_layer.objects.active = obj
file_path = os.path.join(output_dir, obj.name + ".glb")
bpy.ops.export_scene.gltf(filepath=file_path,
use_selection=True,
check_existing=False,
export_format='GLB',
export_texture_dir='textures',
export_texcoords=True,
export_normals=True,
export_tangents=True,
export_materials='EXPORT',
export_image_format='NONE',
export_colors=True,
use_mesh_edges=False,
use_mesh_vertices=False,
export_cameras=False,
use_visible=False,
use_renderable=False,
export_yup=True,
export_apply=True,
export_animations=True,
export_force_sampling=True,
export_def_bones=False,
export_current_frame=False,
export_morph=True,
export_morph_animation=False,
export_morph_normal=True,
export_morph_tangent=True,
export_lights=False,
export_skins=True)
with open(file_path + ".json", "w", newline="\n") as json_data:
json.dump(desc, json_data, indent=2, sort_keys=True)
json_data.write("\n")
exported.append((furniture_id, file_path))
print("Exported %s to %s" % (furniture_id, file_path))
if not exported:
raise RuntimeError("no furniture-* root objects found in " +
bpy.data.filepath)
if SUCCESS_OUTPUT:
with open(SUCCESS_OUTPUT, "w", newline="\n") as marker:
marker.write("ok\n")
return exported
export_root_objects_to_gltf(outdir)
Binary file not shown.
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Write the furniture gallery's static HTML index (stdlib only)."""
import argparse
import html
import json
from pathlib import Path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--root", required=True)
parser.add_argument("--atlas", default="../furniture-atlas.png")
args = parser.parse_args()
root = Path(args.root)
entries = []
for manifest in sorted(root.glob("manifest-*.json")):
entries.extend(json.loads(manifest.read_text()))
entries.sort(key=lambda entry: entry["id"])
rows = []
for entry in entries:
u0, u1, v0, v1 = entry["uv"]
style = (
"left:%.4f%%;top:%.4f%%;width:%.4f%%;height:%.4f%%" %
(u0 * 100.0, (1.0 - v1) * 100.0,
(u1 - u0) * 100.0, (v1 - v0) * 100.0))
bounds = entry.get("bounds")
bounds_text = "none"
if bounds:
bounds_text = "%s .. %s" % (bounds["min"], bounds["max"])
rows.append(
"<section class='asset'><h2>%s</h2>"
"<p><b>Layer:</b> %s &nbsp; <b>Tags:</b> %s</p>"
"<p class='bounds'><b>Bounds:</b> %s%s</p>"
"<div class='views'>"
"<div><h3>Asset / pivot / bounds / POIs</h3>"
"<img class='render' src='%s'></div>"
"<div><h3>Atlas UV region</h3>"
"<div class='atlas'><img src='%s'>"
"<div class='uv' style='%s'></div></div></div>"
"</div></section>" %
(html.escape(entry["id"]), html.escape(entry["layer"]),
html.escape(", ".join(entry["tags"])),
html.escape(bounds_text),
" + collision proxy" if entry.get("collisionBounds") else "",
html.escape(entry["render"]), html.escape(args.atlas), style))
page = """<!doctype html>
<meta charset='utf-8'>
<title>Furniture export gallery</title>
<style>
body { font-family: sans-serif; background: #202124; color: #ddd;
margin: 2em; }
.asset { border-top: 1px solid #555; padding-top: 1em; margin-top: 2em; }
.views { display: flex; gap: 2em; align-items: flex-start; }
.views > div { flex: 1; min-width: 0; }
.render { width: 100%; image-rendering: auto; background: #111; }
.atlas { position: relative; width: 100%; }
.atlas img { width: 100%; display: block; }
.uv { position: absolute; border: 3px solid #f33; box-sizing: border-box;
pointer-events: none; }
.bounds { color: #aaa; font-size: 0.9em; }
</style>
<h1>Furniture export gallery</h1>
<p>Generated from the baked Blender sources. Green = physical bounds,
red = collision bounds, blue = occupancy cells, axes = pivot, spheres =
action/position markers. The red rectangle on each atlas shows the
asset's UV region.</p>
""" + "\n".join(rows)
(root / "index.html").write_text(page)
print("Furniture gallery index: %d assets" % len(entries))
if __name__ == "__main__":
main()
@@ -0,0 +1,79 @@
import os
import sys
import bpy
# Stack deterministic per-blend furniture atlas bakes into one shared atlas.
# bake_furniture.py remaps each source's UVs into the matching vertical band.
#
# Usage:
# blender --factory-startup -b -P merge_furniture_atlases.py -- \
# <atlas.png> <atlas.material> <part0.png> [part1.png ...]
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(argv) < 3:
raise RuntimeError(
"merge_furniture_atlases.py needs output png/material and inputs")
out_png = argv[0]
out_material = argv[1]
inputs = argv[2:]
images = []
width = None
part_height = None
for path in inputs:
if not os.path.exists(path):
raise RuntimeError("missing furniture atlas part: " + path)
image = bpy.data.images.load(path, check_existing=False)
images.append((path, image))
if width is None:
width, part_height = image.size
elif image.size[0] != width or image.size[1] != part_height:
raise RuntimeError("atlas size mismatch in " + path)
if width is None or width <= 0 or part_height <= 0:
raise RuntimeError("no furniture atlas parts supplied")
part_pixels = width * part_height * 4
merged = []
for path, image in images:
pixels = list(image.pixels)
if len(pixels) != part_pixels:
raise RuntimeError("unexpected pixel count in " + path)
coverage = sum(1 for i in range(3, len(pixels), 4) if pixels[i] > 0.0)
if coverage == 0:
raise RuntimeError("furniture atlas part is empty: " + path)
merged.extend(pixels)
result = bpy.data.images.new("FurnitureColor", width=width,
height=part_height * len(images), alpha=True)
result.pixels = merged
os.makedirs(os.path.dirname(out_png), exist_ok=True)
os.makedirs(os.path.dirname(out_material), exist_ok=True)
result.filepath_raw = out_png
result.file_format = 'PNG'
result.save()
material = """// Generated by merge_furniture_atlases.py; do not edit.
material M_Furniture_Atlas
{
technique
{
pass
{
texture_unit
{
texture furniture-atlas.png
filtering linear linear linear
}
}
}
}
"""
with open(out_material, "w", newline="\n") as file:
file.write(material)
print("Furniture atlas merged: %d source band(s), %dx%d -> %s" %
(len(images), width, part_height * len(images), out_png))
print("Furniture material written: " + out_material)
@@ -0,0 +1,253 @@
"""Render one preview image per furniture root plus a gallery manifest.
Invoke on a *_baked.blend from the furniture pipeline:
blender -b furniture_baked.blend -Y -P render_furniture_gallery.py -- \
<gallery-dir> <definition-dir>
The manifest is consumed by generate_furniture_gallery_index.py. Rendering
uses Cycles CPU so it also works on headless build machines.
"""
import json
import math
import os
import sys
import bpy
from mathutils import Vector
argv = sys.argv[sys.argv.index("--") + 1:]
GALLERY_DIR = argv[0]
DEFINITION_DIR = argv[1]
os.makedirs(GALLERY_DIR, exist_ok=True)
def ogre_to_blender(v):
return Vector((v[0], -v[2], v[1]))
def material(name, color, emission=0.0):
mat = bpy.data.materials.get(name)
if mat:
return mat
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1.0)
bsdf.inputs["Roughness"].default_value = 0.8
if emission:
bsdf.inputs["Emission"].default_value = (*color, 1.0)
bsdf.inputs["Emission Strength"].default_value = emission
return mat
bounds_mat = material("GalleryBounds", (0.1, 0.8, 0.2), 1.0)
collision_mat = material("GalleryCollision", (0.9, 0.1, 0.1), 1.0)
cell_mat = material("GalleryCells", (0.1, 0.4, 0.9), 0.5)
pivot_mats = [material("GalleryX", (1, 0, 0), 1.0),
material("GalleryY", (0, 1, 0), 1.0),
material("GalleryZ", (0, 0, 1), 1.0)]
action_mat = material("GalleryAction", (1.0, 0.5, 0.05), 1.0)
position_mat = material("GalleryPosition", (0.2, 0.6, 1.0), 1.0)
floor_mat = material("GalleryFloor", (0.18, 0.18, 0.18))
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 4
scene.render.resolution_x = 480
scene.render.resolution_y = 360
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = 'PNG'
scene.render.film_transparent = False
# Neutral floor, camera and lights are shared by every render.
bpy.ops.mesh.primitive_plane_add(size=200.0, location=(0, 0, -0.03))
floor = bpy.context.object
floor.name = "GalleryFloor"
floor.data.materials.append(floor_mat)
bpy.ops.object.camera_add()
camera = bpy.context.object
scene.camera = camera
camera.data.lens = 50
for name, location, energy, size in (
("Key", (3, 4, 6), 600, 4),
("Fill", (-4, 1, 3), 350, 4),
("Rim", (0, -4, 4), 450, 3)):
bpy.ops.object.light_add(type='AREA', location=location)
lamp = bpy.context.object
lamp.name = "Gallery" + name
lamp.data.energy = energy
lamp.data.size = size
def hide_tree(obj, hide):
obj.hide_render = hide
for child in obj.children:
hide_tree(child, hide)
def line_object(name, points, mat, width=0.008):
curve = bpy.data.curves.new(name, 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = width
curve.bevel_resolution = 1
spline = curve.splines.new('POLY')
spline.points.add(len(points) - 1)
for point, co in zip(spline.points, points):
point.co = (*co, 1.0)
obj = bpy.data.objects.new(name, curve)
scene.collection.objects.link(obj)
obj.data.materials.append(mat)
return obj
def box_edges(name, minimum, maximum, mat):
corners = []
for x in (minimum[0], maximum[0]):
for y in (minimum[1], maximum[1]):
for z in (minimum[2], maximum[2]):
corners.append(Vector((x, y, z)))
indices = [(0, 1), (0, 2), (0, 4), (1, 3), (1, 5), (2, 3),
(2, 6), (3, 7), (4, 5), (4, 6), (5, 7), (6, 7)]
for index, (a, b) in enumerate(indices):
line_object(name + str(index), [corners[a], corners[b]], mat)
def sphere(name, position, mat, radius):
bpy.ops.mesh.primitive_uv_sphere_add(segments=12, ring_count=6,
radius=radius, location=position)
obj = bpy.context.object
obj.name = name
obj.data.materials.append(mat)
return obj
def uv_bounds(root):
result = [1.0, 0.0, 1.0, 0.0]
found = False
stack = list(root.children)
while stack:
obj = stack.pop(0)
stack.extend(obj.children)
if obj.type != 'MESH' or obj.name.startswith("collision-"):
continue
for layer in obj.data.uv_layers:
for loop in layer.data:
u, v = loop.uv
result[0] = min(result[0], u)
result[1] = max(result[1], u)
result[2] = min(result[2], v)
result[3] = max(result[3], v)
found = True
return result if found else [0.0, 1.0, 0.0, 1.0]
roots = [obj for obj in scene.objects
if obj.parent is None and obj.name.startswith("furniture-")]
manifest = []
for root in sorted(roots, key=lambda obj: obj.name):
definition_path = os.path.join(DEFINITION_DIR, root.name + ".glb.json")
with open(definition_path) as source:
definition = json.load(source)
# Definitions and GLBs are exported with the root at the origin;
# use the same pivot here (many source roots have authoring offsets).
root.location = (0.0, 0.0, 0.0)
for other in roots:
hide_tree(other, True)
hide_tree(root, False)
helpers_start = set(scene.objects)
bounds = definition.get("bounds")
if bounds:
minimum = ogre_to_blender(bounds["min"])
maximum = ogre_to_blender(bounds["max"])
# ogre_to_blender swaps Y/Z and negates one axis; reorder.
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
box_edges("PhysicalBounds", low, high, bounds_mat)
center = (low + high) * 0.5
size = high - low
else:
center = Vector((0, 0, 0.5))
size = Vector((1, 1, 1))
if "collisionBounds" in definition:
collision = definition["collisionBounds"]
minimum = ogre_to_blender(collision["min"])
maximum = ogre_to_blender(collision["max"])
low = Vector(tuple(min(a, b) for a, b in zip(minimum, maximum)))
high = Vector(tuple(max(a, b) for a, b in zip(minimum, maximum)))
box_edges("CollisionBounds", low, high, collision_mat)
# Pivot axes and occupancy-cell outlines at the root origin.
line_object("PivotX", [(0, 0, 0), (0.35, 0, 0)], pivot_mats[0], 0.012)
line_object("PivotY", [(0, 0, 0), (0, 0.35, 0)], pivot_mats[1], 0.012)
line_object("PivotZ", [(0, 0, 0), (0, 0, 0.35)], pivot_mats[2], 0.012)
if bounds:
cell_size = 2.0
bmin = bounds["min"]
bmax = bounds["max"]
min_cx = math.floor(bmin[0] / cell_size)
min_cz = math.floor(bmin[2] / cell_size)
max_cx = math.ceil((bmax[0] - 0.0001) / cell_size) - 1
max_cz = math.ceil((bmax[2] - 0.0001) / cell_size) - 1
for cx in range(min_cx, max_cx + 1):
for cz in range(min_cz, max_cz + 1):
points = [(cx * cell_size, -cz * cell_size, -0.01),
((cx + 1) * cell_size, -cz * cell_size, -0.01),
((cx + 1) * cell_size,
-(cz + 1) * cell_size, -0.01),
(cx * cell_size, -(cz + 1) * cell_size, -0.01),
(cx * cell_size, -cz * cell_size, -0.01)]
line_object("OccupancyCell", points, cell_mat, 0.006)
radius = max(size.length * 0.025, 0.025)
for action in definition.get("actions", []):
position = (action["position_x"], action["position_y"],
action["position_z"])
sphere("ActionMarker", ogre_to_blender(position), action_mat,
radius)
for marker in action.get("positions", []):
position = (marker["position_x"], marker["position_y"],
marker["position_z"])
sphere("PositionMarker", ogre_to_blender(position),
position_mat, radius * 0.7)
for marker in definition.get("positions", []):
position = (marker["position_x"], marker["position_y"],
marker["position_z"])
sphere("PositionMarker", ogre_to_blender(position),
position_mat, radius * 0.7)
distance = max(size.length, 0.8) * 1.8
camera.location = center + Vector((distance * 0.8,
distance * 1.1,
distance * 0.65))
camera.rotation_euler = (center - camera.location).to_track_quat(
'-Z', 'Y').to_euler()
camera.data.lens = 45
output_name = root.name + ".png"
scene.render.filepath = os.path.join(GALLERY_DIR, output_name)
bpy.ops.render.render(write_still=True)
manifest.append({
"id": definition["id"],
"render": output_name,
"layer": definition.get("layer", "normal"),
"tags": definition.get("tags", []),
"bounds": definition.get("bounds"),
"collisionBounds": definition.get("collisionBounds"),
"uv": uv_bounds(root),
})
for helper in set(scene.objects) - helpers_start:
bpy.data.objects.remove(helper, do_unlink=True)
with open(os.path.join(GALLERY_DIR,
"manifest-" + os.path.basename(DEFINITION_DIR) +
".json"),
"w") as output:
json.dump(manifest, output, indent=2)
print("Furniture gallery: %d assets rendered" % len(manifest))
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Validate generated furniture GLB/JSON artifacts after the CMake export.
Checks the phase 6.5 shared-atlas contract without starting OGRE:
schema metadata exists, GLBs carry no embedded images, every material is the
shared material, UVs stay in the owning source's vertical atlas band, and
mesh variants sharing a Blender mesh datablock keep identical UV bounds.
"""
import json
import os
import struct
import sys
from pathlib import Path
COMPONENT_FLOAT = 5126
def fail(message):
raise RuntimeError(message)
def read_glb(path):
data = path.read_bytes()
if len(data) < 20 or data[:4] != b"glTF":
fail(f"{path}: not a GLB")
json_length, json_type = struct.unpack_from("<II", data, 12)
if json_type != 0x4E4F534A:
fail(f"{path}: first chunk is not JSON")
document = json.loads(data[20:20 + json_length])
offset = 20 + json_length
bin_data = b""
if offset + 8 <= len(data):
bin_length, bin_type = struct.unpack_from("<II", data, offset)
if bin_type == 0x004E4942:
bin_data = data[offset + 8:offset + 8 + bin_length]
return document, bin_data
def accessor_floats(document, bin_data, accessor_index):
accessor = document["accessors"][accessor_index]
if accessor["componentType"] != COMPONENT_FLOAT:
fail(f"accessor {accessor_index} is not float")
view = document["bufferViews"][accessor["bufferView"]]
offset = view.get("byteOffset", 0) + accessor.get("byteOffset", 0)
components = {"VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
count = accessor["count"] * components
return struct.unpack_from("<%df" % count, bin_data, offset)
def uv_bounds(document, bin_data):
result = []
for mesh_index, mesh in enumerate(document.get("meshes", [])):
mesh_name = mesh.get("name", f"mesh{mesh_index}")
for primitive in mesh.get("primitives", []):
accessor_index = primitive.get("attributes", {}).get("TEXCOORD_0")
if accessor_index is None:
continue
values = accessor_floats(document, bin_data, accessor_index)
us = values[::2]
vs = values[1::2]
result.append((mesh_name, min(us), max(us), min(vs), max(vs)))
return result
def validate_source(source_root, source_name, band_index, band_count):
source_dir = source_root / source_name
if not source_dir.is_dir():
fail(f"missing output directory {source_dir}")
json_paths = sorted(source_dir.glob("furniture-*.json"))
if not json_paths:
fail(f"no furniture definitions in {source_dir}")
shared_uv = {}
min_v = 1.0 - float(band_index + 1) / band_count - 0.002
max_v = 1.0 - float(band_index) / band_count + 0.002
mesh_count = 0
for json_path in json_paths:
definition = json.loads(json_path.read_text())
if definition.get("source") != "blender":
fail(f"{json_path}: source is not blender")
if definition.get("materialName") != "M_Furniture_Atlas":
fail(f"{json_path}: wrong materialName")
if not definition.get("id") or not definition.get("mesh"):
fail(f"{json_path}: missing id/mesh")
if "bounds" not in definition:
fail(f"{json_path}: missing physical bounds")
glb_path = source_dir / definition["mesh"]
if not glb_path.is_file():
fail(f"{json_path}: referenced mesh is missing")
document, bin_data = read_glb(glb_path)
if document.get("images"):
fail(f"{glb_path}: embeds an image instead of using the shared atlas")
materials = document.get("materials", [])
if not materials or any(
material.get("name") != "M_Furniture_Atlas"
for material in materials):
fail(f"{glb_path}: does not use only M_Furniture_Atlas")
for mesh_name, min_u, max_u, mesh_min_v, mesh_max_v in uv_bounds(
document, bin_data):
mesh_count += 1
if mesh_min_v < min_v or mesh_max_v > max_v:
fail(
f"{glb_path}:{mesh_name}: UV V range "
f"{mesh_min_v:.4f}..{mesh_max_v:.4f} escapes band "
f"{min_v:.4f}..{max_v:.4f}")
key = (source_name, mesh_name)
bounds = (min_u, max_u, mesh_min_v, mesh_max_v)
previous = shared_uv.get(key)
if previous:
if any(abs(a - b) > 0.0005 for a, b in zip(previous, bounds)):
fail(
f"{glb_path}:{mesh_name}: shared mesh UV bounds "
f"changed between variants {previous} -> {bounds}")
else:
shared_uv[key] = bounds
return len(json_paths), mesh_count
def main():
args = sys.argv[1:]
if len(args) < 6 or args[0] != "--root":
fail("usage: validate_furniture_export.py --root <dir> "
"--source <name> <band> <count> [...]")
root = Path(args[1])
atlas_png = root / "furniture-atlas.png"
atlas_material = root / "furniture-atlas.material"
if not atlas_png.is_file() or atlas_png.stat().st_size == 0:
fail("shared furniture-atlas.png is missing/empty")
if "M_Furniture_Atlas" not in atlas_material.read_text():
fail("shared material does not define M_Furniture_Atlas")
definitions = 0
meshes = 0
i = 2
while i < len(args):
if args[i] != "--source" or i + 3 >= len(args):
fail("malformed --source arguments")
count, mesh_count = validate_source(root, args[i + 1],
int(args[i + 2]),
int(args[i + 3]))
definitions += count
meshes += mesh_count
i += 4
print(f"Furniture export validation: {definitions} definitions, "
f"{meshes} mesh primitives, shared atlas OK")
if __name__ == "__main__":
main()