Phase 6.5 checkpoint
This commit is contained in:
@@ -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 <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()
|
||||
Reference in New Issue
Block a user