diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DaisyColony.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DaisyColony.fbx new file mode 100644 index 0000000..6ac58b8 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DaisyColony.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DistantRidge.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DistantRidge.fbx new file mode 100644 index 0000000..9b99804 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DistantRidge.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FarHorizon.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FarHorizon.fbx new file mode 100644 index 0000000..76e9080 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FarHorizon.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FernCluster.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FernCluster.fbx new file mode 100644 index 0000000..9665d79 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FernCluster.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GentleHill.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GentleHill.fbx new file mode 100644 index 0000000..bcfc450 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GentleHill.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftGold.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftGold.fbx new file mode 100644 index 0000000..458fb81 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftGold.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftMint.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftMint.fbx new file mode 100644 index 0000000..25d169b Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftMint.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LavenderLupineColony.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LavenderLupineColony.fbx new file mode 100644 index 0000000..2c187ef Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LavenderLupineColony.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LowLimestone.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LowLimestone.fbx new file mode 100644 index 0000000..91a3330 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LowLimestone.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MeadowClearing.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MeadowClearing.fbx new file mode 100644 index 0000000..b0f9898 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MeadowClearing.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MintShrub.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MintShrub.fbx new file mode 100644 index 0000000..d766831 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MintShrub.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MossStoneSteps.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MossStoneSteps.fbx new file mode 100644 index 0000000..199c3c5 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MossStoneSteps.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/OpenStoneArch.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/OpenStoneArch.fbx new file mode 100644 index 0000000..65bd368 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/OpenStoneArch.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PebbleScatter.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PebbleScatter.fbx new file mode 100644 index 0000000..107868a Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PebbleScatter.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PinkCosmosColony.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PinkCosmosColony.fbx new file mode 100644 index 0000000..e9dabcf Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PinkCosmosColony.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundCanopyTree.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundCanopyTree.fbx new file mode 100644 index 0000000..fa87ba3 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundCanopyTree.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundLimestone.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundLimestone.fbx new file mode 100644 index 0000000..27ac3f5 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundLimestone.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageCanopyTree.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageCanopyTree.fbx new file mode 100644 index 0000000..152c354 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageCanopyTree.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageShrub.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageShrub.fbx new file mode 100644 index 0000000..899f10c Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageShrub.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SlateBoulder.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SlateBoulder.fbx new file mode 100644 index 0000000..18d46b3 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SlateBoulder.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/TallWindTree.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/TallWindTree.fbx new file mode 100644 index 0000000..53563a1 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/TallWindTree.fbx differ diff --git a/SourceAssets/Models/Meadow/MeadowCandidates.blend b/SourceAssets/Models/Meadow/MeadowCandidates.blend new file mode 100644 index 0000000..e9f3d31 Binary files /dev/null and b/SourceAssets/Models/Meadow/MeadowCandidates.blend differ diff --git a/SourceAssets/Models/Meadow/README.md b/SourceAssets/Models/Meadow/README.md new file mode 100644 index 0000000..6247f3d --- /dev/null +++ b/SourceAssets/Models/Meadow/README.md @@ -0,0 +1,20 @@ +# 자연 풍경 모델 후보 + +이 묶음은 사용자 미술 검토를 위한 후보다. 현재 게임의 씬·프리팹·스테이지·공통 재질에 연결하거나 기존 배경을 채택 교체하지 않았다. Unity 메타데이터는 후속 import 담당자가 생성한다. + +`MeadowCandidates.blend`에는 바닥 피벗의 독립 메시 21개와 정리된 지형·나무·작은 식생·바위/발견 소품 컬렉션이 있다. `Preview_ClearMeadow`, `Preview_FlowerHills`, `Preview_StoneHills`는 각각 1–10 맑은 초원, 11–20 꽃언덕, 21–30 바위언덕의 조립 검토 장면이다. 내부 테마 식별자는 Garden/Cloud/Workshop로 기록하지만 게임 데이터는 수정하지 않는다. 중앙은 퍼즐 구를 위한 낮은 밀도의 빈 공간이고 주변 지형과 원경이 이어진다. + +나무는 휘어진 줄기·드러난 가지·뿌리·곡면 수관·잎을 갖는다. 꽃은 두께 있는 꽃잎·꽃 중심·휘어진 줄기·잎으로 나뉘며, 풀과 고사리는 개별 굽은 잎으로 제작했다. 바위는 큰 면 변화와 부드러운 모서리를 갖는다. 모든 표면은 이름 있는 재질 슬롯과 고정 Base Color/Roughness를 사용한다. Blender 전용 procedural 표면에 의존하지 않으므로 별도 텍스처가 필요하지 않다. 팔레트는 sRGB에서 선형 색으로 변환한 값이며 실제 값은 provenance에 함께 기록한다. + +원본은 m·Z-up이며 FBX는 -Z forward/Y-up, 적용된 unit scale, 애니메이션 없음이다. Unity 후속 import는 scale 1·축 변환과 imported root 회전 보존을 확인한다. 가까운 발견 소품은 보이는 형상에 맞는 Collider가 필요하다. `OpenStoneArch`의 열린 구멍을 둘러싸는 BoxCollider로 막거나 앞 hit를 무시하지 않는다. 먼 지형과 작은 식생에는 입력 Collider를 추가하지 않는다. 지형·단차·아치의 실제 배치는 기존 구 초기 위치, 접촉 후보, 회전·확대 범위와 발견 가능성을 별도로 검증해야 한다. + +저장소 루트에서 재현한다. 현재 승인된 검토 출력 경로는 호출 시 `--review-dir`로 지정할 수 있다. + +```powershell +& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python-exit-code 1 --python tools/models/build_meadow.py -- --review-dir artifacts/verification/VisualUpgrade/ModelReview/Meadow +& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python-exit-code 1 --python tools/models/build_meadow.py -- --verify-only --review-dir artifacts/verification/VisualUpgrade/ModelReview/Meadow +``` + +검토 출력에는 풍경 overview 3장, 소품 catalog 4장, 가지/잎·꽃잎/줄기/잎·아치/단차 closeup 3장, 렌더별 source SHA·scene·frame·camera·GPU·해상도 정보가 들어 있다. 원본 재열기, native 메시의 피벗·UV·재질·상향 지형 법선·삼각형 수, 실제 FBX 재import의 축/크기/재질/UV, 아치 구멍과 기둥의 ray 대조군, 파일 SHA/bytes 검사는 `source-verification.json`에 남긴다. 이 검사는 Unity import, 실제 퍼즐 조작·발견, Android 성능과 사용자 승인 검사를 대체하지 않는다. + +자체 제작 원본·도구·내보낸 파일의 실제 SHA/bytes와 Blender 버전/build hash·export 설정·재질 값은 이 디렉터리의 별도 `provenance.json`으로 연결한다. 참고 이미지에서는 청록 하늘과 둥근 자연·파스텔 식생·따뜻한 낮의 방향만 참고했고 캐릭터·무기·UI·화면 구성을 복제하지 않았다. diff --git a/SourceAssets/Models/Meadow/provenance.json b/SourceAssets/Models/Meadow/provenance.json new file mode 100644 index 0000000..8f32c30 --- /dev/null +++ b/SourceAssets/Models/Meadow/provenance.json @@ -0,0 +1,1014 @@ +{ + "schemaVersion": 1, + "status": "CANDIDATE_USER_REVIEW_REQUIRED_NOT_ADOPTED", + "creator": "MagnetismHeroes project-authored native Blender geometry via tools/models/build_meadow.py", + "license": "Self-created project artwork; no third-party mesh or texture assets; no copied reference characters, guns, UI, or composition", + "reference": "cartoon-landscape.png viewed only for cyan sky, rounded nature, pastel vegetation, warm soft daylight", + "tool": "Blender 5.2.2 LTS", + "toolBuildHash": "d13f752e3b9c", + "source": "SourceAssets/Models/Meadow/MeadowCandidates.blend", + "coordinateConvention": "meters Z-up native; FBX -Z forward/Y-up; applied unit scale; floor pivots; later Unity globalScale=1 bakeAxisConversion=true and preserve imported root rotation", + "exportSettings": { + "use_selection": true, + "object_types": [ + "MESH" + ], + "axis_forward": "-Z", + "axis_up": "Y", + "apply_unit_scale": true, + "apply_scale_options": "FBX_SCALE_ALL", + "use_mesh_modifiers": true, + "bake_anim": false, + "add_leaf_bones": false, + "path_mode": "AUTO" + }, + "materialTransfer": "native named material slots; constant Base Color and Roughness; UVMap; no Blender-only procedural surface or external texture dependency", + "materials": [ + { + "name": "GroundMint", + "authoredSRGB": [ + 0.53, + 0.74, + 0.5 + ], + "linearBaseColor": [ + 0.24286681413650513, + 0.5070785284042358, + 0.21404114365577698, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "GroundSage", + "authoredSRGB": [ + 0.59, + 0.77, + 0.49 + ], + "linearBaseColor": [ + 0.3069993257522583, + 0.5542217493057251, + 0.2049017697572708, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "SoilWarm", + "authoredSRGB": [ + 0.52, + 0.42, + 0.29 + ], + "linearBaseColor": [ + 0.23302200436592102, + 0.1473190039396286, + 0.06838485598564148, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "LeafMint", + "authoredSRGB": [ + 0.45, + 0.67, + 0.44 + ], + "linearBaseColor": [ + 0.17064493894577026, + 0.4064483046531677, + 0.16264718770980835, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "LeafSage", + "authoredSRGB": [ + 0.53, + 0.69, + 0.41 + ], + "linearBaseColor": [ + 0.24286681413650513, + 0.4338795244693756, + 0.1399848908185959, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "LeafLight", + "authoredSRGB": [ + 0.62, + 0.77, + 0.46 + ], + "linearBaseColor": [ + 0.34239163994789124, + 0.5542217493057251, + 0.17886750400066376, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "LeafShade", + "authoredSRGB": [ + 0.31, + 0.56, + 0.45 + ], + "linearBaseColor": [ + 0.07828831672668457, + 0.27383843064308167, + 0.17064493894577026, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "PineMint", + "authoredSRGB": [ + 0.38, + 0.66, + 0.55 + ], + "linearBaseColor": [ + 0.11928018927574158, + 0.3931231200695038, + 0.2632734179496765, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "BarkHoney", + "authoredSRGB": [ + 0.52, + 0.36, + 0.21 + ], + "linearBaseColor": [ + 0.23302200436592102, + 0.10653922706842422, + 0.036306485533714294, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "BarkLight", + "authoredSRGB": [ + 0.68, + 0.49, + 0.3 + ], + "linearBaseColor": [ + 0.4200332760810852, + 0.2049017697572708, + 0.07323895394802094, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "Stem", + "authoredSRGB": [ + 0.32, + 0.52, + 0.27 + ], + "linearBaseColor": [ + 0.08353512734174728, + 0.23302200436592102, + 0.059253521263599396, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "GrassMint", + "authoredSRGB": [ + 0.49, + 0.73, + 0.46 + ], + "linearBaseColor": [ + 0.2049017697572708, + 0.4919050335884094, + 0.17886750400066376, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "GrassGold", + "authoredSRGB": [ + 0.69, + 0.8, + 0.46 + ], + "linearBaseColor": [ + 0.4338795244693756, + 0.6038273572921753, + 0.17886750400066376, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "PetalCream", + "authoredSRGB": [ + 0.98, + 0.88, + 0.59 + ], + "linearBaseColor": [ + 0.9551045894622803, + 0.7484140992164612, + 0.3069993257522583, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "PetalPink", + "authoredSRGB": [ + 0.9, + 0.43, + 0.56 + ], + "linearBaseColor": [ + 0.7874122858047485, + 0.15487249195575714, + 0.27383843064308167, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "PetalLavender", + "authoredSRGB": [ + 0.54, + 0.42, + 0.77 + ], + "linearBaseColor": [ + 0.2529500722885132, + 0.1473190039396286, + 0.5542217493057251, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "PollenHoney", + "authoredSRGB": [ + 0.94, + 0.58, + 0.13 + ], + "linearBaseColor": [ + 0.868899405002594, + 0.295699805021286, + 0.015325203537940979, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "StoneCream", + "authoredSRGB": [ + 0.59, + 0.64, + 0.57 + ], + "linearBaseColor": [ + 0.3069993257522583, + 0.36724647879600525, + 0.2846466898918152, + 1.0 + ], + "roughness": 0.7799999713897705 + }, + { + "name": "StoneLight", + "authoredSRGB": [ + 0.75, + 0.76, + 0.65 + ], + "linearBaseColor": [ + 0.5225215554237366, + 0.5382355451583862, + 0.38005632162094116, + 1.0 + ], + "roughness": 0.7799999713897705 + }, + { + "name": "StoneShade", + "authoredSRGB": [ + 0.42, + 0.53, + 0.49 + ], + "linearBaseColor": [ + 0.1473190039396286, + 0.24286681413650513, + 0.2049017697572708, + 1.0 + ], + "roughness": 0.7799999713897705 + }, + { + "name": "StoneBlue", + "authoredSRGB": [ + 0.58, + 0.7, + 0.71 + ], + "linearBaseColor": [ + 0.295699805021286, + 0.4479884207248688, + 0.4623613655567169, + 1.0 + ], + "roughness": 0.7799999713897705 + }, + { + "name": "PathSand", + "authoredSRGB": [ + 0.76, + 0.77, + 0.57 + ], + "linearBaseColor": [ + 0.5382355451583862, + 0.5542217493057251, + 0.2846466898918152, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "RidgeTeal", + "authoredSRGB": [ + 0.54, + 0.73, + 0.72 + ], + "linearBaseColor": [ + 0.2529500722885132, + 0.4919050335884094, + 0.47699978947639465, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "RidgeLight", + "authoredSRGB": [ + 0.61, + 0.78, + 0.74 + ], + "linearBaseColor": [ + 0.33034372329711914, + 0.5704815983772278, + 0.5070785284042358, + 1.0 + ], + "roughness": 0.6700000166893005 + }, + { + "name": "RidgeFar", + "authoredSRGB": [ + 0.66, + 0.82, + 0.83 + ], + "linearBaseColor": [ + 0.3931231200695038, + 0.6382831931114197, + 0.6559306979179382, + 1.0 + ], + "roughness": 0.6700000166893005 + } + ], + "assetNames": [ + "DaisyColony", + "DistantRidge", + "FarHorizon", + "FernCluster", + "GentleHill", + "GrassTuftGold", + "GrassTuftMint", + "LavenderLupineColony", + "LowLimestone", + "MeadowClearing", + "MintShrub", + "MossStoneSteps", + "OpenStoneArch", + "PebbleScatter", + "PinkCosmosColony", + "RoundCanopyTree", + "RoundLimestone", + "SageCanopyTree", + "SageShrub", + "SlateBoulder", + "TallWindTree" + ], + "meshContracts": [ + { + "name": "DaisyColony", + "vertices": 3225, + "triangles": 6210, + "materials": [ + "Stem", + "LeafSage", + "PetalCream", + "PollenHoney" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.7528204917907715, + -0.619702160358429, + 0.0 + ], + "boundsMax": [ + 0.6799558401107788, + 0.4841458201408386, + 0.9964258074760437 + ] + }, + { + "name": "DistantRidge", + "vertices": 1089, + "triangles": 2174, + "materials": [ + "RidgeTeal", + "RidgeLight", + "SoilWarm" + ], + "colliderGuidance": "none: distant decoration", + "boundsMin": [ + -12.171285629272461, + -6.370273113250732, + 0.0 + ], + "boundsMax": [ + 12.394749641418457, + 5.770437717437744, + 4.0 + ] + }, + { + "name": "FarHorizon", + "vertices": 1089, + "triangles": 2174, + "materials": [ + "RidgeFar", + "SoilWarm" + ], + "colliderGuidance": "none: distant decoration", + "boundsMin": [ + -15.214107513427734, + -7.431985378265381, + 0.0 + ], + "boundsMax": [ + 15.493437767028809, + 6.732177257537842, + 5.0 + ] + }, + { + "name": "FernCluster", + "vertices": 3906, + "triangles": 7504, + "materials": [ + "LeafSage", + "Stem" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.6333858966827393, + -0.684700608253479, + 0.0 + ], + "boundsMax": [ + 0.702409565448761, + 0.684700608253479, + 0.48928114771842957 + ] + }, + { + "name": "GentleHill", + "vertices": 1089, + "triangles": 2174, + "materials": [ + "GroundMint", + "GroundSage", + "SoilWarm" + ], + "colliderGuidance": "none: peripheral scenery", + "boundsMin": [ + -5.578505992889404, + -4.565362453460693, + 0.0 + ], + "boundsMax": [ + 5.68092679977417, + 4.1354804039001465, + 1.5499999523162842 + ] + }, + { + "name": "GrassTuftGold", + "vertices": 1026, + "triangles": 1976, + "materials": [ + "GrassGold", + "GrassMint" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.30438318848609924, + -0.25960108637809753, + 0.0 + ], + "boundsMax": [ + 0.2708684802055359, + 0.39073023200035095, + 0.4192138910293579 + ] + }, + { + "name": "GrassTuftMint", + "vertices": 1026, + "triangles": 1976, + "materials": [ + "GrassGold", + "GrassMint" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.3801674544811249, + -0.3744021952152252, + 0.0 + ], + "boundsMax": [ + 0.4355301260948181, + 0.34059640765190125, + 0.4161490499973297 + ] + }, + { + "name": "LavenderLupineColony", + "vertices": 4836, + "triangles": 9304, + "materials": [ + "Stem", + "LeafSage", + "PetalLavender" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.8815386295318604, + -0.603098452091217, + 0.0 + ], + "boundsMax": [ + 0.5153007507324219, + 0.4247342050075531, + 1.0335596799850464 + ] + }, + { + "name": "LowLimestone", + "vertices": 536, + "triangles": 1068, + "materials": [ + "StoneCream", + "StoneLight", + "StoneShade" + ], + "colliderGuidance": "mesh on visible near rock only", + "boundsMin": [ + -0.7921396493911743, + -0.4920438528060913, + 0.0 + ], + "boundsMax": [ + 0.7447836995124817, + 0.5415751338005066, + 0.6399999856948853 + ] + }, + { + "name": "MeadowClearing", + "vertices": 1089, + "triangles": 2174, + "materials": [ + "GroundMint", + "GroundSage", + "SoilWarm" + ], + "colliderGuidance": "future gameplay floor only; top .14m authored, placement must align existing floor", + "boundsMin": [ + -6.288497447967529, + -5.308560848236084, + 0.0 + ], + "boundsMax": [ + 6.403954029083252, + 4.8086981773376465, + 0.17499999701976776 + ] + }, + { + "name": "MintShrub", + "vertices": 1524, + "triangles": 2968, + "materials": [ + "BarkHoney", + "LeafMint", + "LeafLight", + "LeafShade" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.7132284641265869, + -0.6781878471374512, + 0.0 + ], + "boundsMax": [ + 0.8508435487747192, + 0.5976070165634155, + 1.1246769428253174 + ] + }, + { + "name": "MossStoneSteps", + "vertices": 3267, + "triangles": 6522, + "materials": [ + "StoneCream", + "StoneLight", + "StoneShade" + ], + "colliderGuidance": "nonconvex mesh matching terraces; integration must preserve existing sphere positions", + "boundsMin": [ + -1.5308481454849243, + -0.6051759719848633, + 0.0 + ], + "boundsMax": [ + 1.5450007915496826, + 0.5481916069984436, + 0.8224999904632568 + ] + }, + { + "name": "OpenStoneArch", + "vertices": 396, + "triangles": 788, + "materials": [ + "StoneCream", + "StoneLight", + "StoneShade" + ], + "colliderGuidance": "nonconvex visible mesh collider; NEVER enclosing BoxCollider; do not ignore front raycast hits", + "boundsMin": [ + -1.4986858367919922, + -0.33498507738113403, + 0.0 + ], + "boundsMax": [ + 1.4955861568450928, + 0.33498507738113403, + 2.049074649810791 + ] + }, + { + "name": "PebbleScatter", + "vertices": 336, + "triangles": 656, + "materials": [ + "StoneCream", + "StoneLight" + ], + "colliderGuidance": "none: small distant or ground decoration", + "boundsMin": [ + -0.5945333242416382, + -0.33430424332618713, + 0.0 + ], + "boundsMax": [ + 0.6162720918655396, + 0.343009889125824, + 0.24940000474452972 + ] + }, + { + "name": "PinkCosmosColony", + "vertices": 2685, + "triangles": 5170, + "materials": [ + "Stem", + "LeafSage", + "PetalPink", + "PollenHoney" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.7044399976730347, + -0.6924564242362976, + 0.0 + ], + "boundsMax": [ + 0.6764088273048401, + 0.3726412057876587, + 0.9467144012451172 + ] + }, + { + "name": "RoundCanopyTree", + "vertices": 2486, + "triangles": 4840, + "materials": [ + "BarkHoney", + "LeafMint", + "LeafLight", + "LeafShade", + "BarkLight" + ], + "colliderGuidance": "mesh on visible near tree only; no enclosing canopy box", + "boundsMin": [ + -1.8872404098510742, + -1.233106017112732, + 0.0 + ], + "boundsMax": [ + 2.143420934677124, + 1.45781672000885, + 3.940213680267334 + ] + }, + { + "name": "RoundLimestone", + "vertices": 563, + "triangles": 1122, + "materials": [ + "StoneCream", + "StoneLight", + "StoneShade" + ], + "colliderGuidance": "mesh on visible near rock only", + "boundsMin": [ + -1.2425415515899658, + -0.7627122402191162, + 0.0 + ], + "boundsMax": [ + 1.1706734895706177, + 0.8404111266136169, + 1.4600000381469727 + ] + }, + { + "name": "SageCanopyTree", + "vertices": 2486, + "triangles": 4840, + "materials": [ + "BarkHoney", + "LeafSage", + "LeafLight", + "LeafShade", + "BarkLight" + ], + "colliderGuidance": "mesh on visible near tree only; no enclosing canopy box", + "boundsMin": [ + -1.8872404098510742, + -1.233106017112732, + 0.0 + ], + "boundsMax": [ + 2.143420934677124, + 1.45781672000885, + 3.940213680267334 + ] + }, + { + "name": "SageShrub", + "vertices": 1524, + "triangles": 2968, + "materials": [ + "BarkHoney", + "LeafSage", + "LeafLight", + "LeafShade" + ], + "colliderGuidance": "none: small decorative vegetation", + "boundsMin": [ + -0.7591184973716736, + -0.6644286513328552, + 0.0 + ], + "boundsMax": [ + 0.8430719971656799, + 0.6201329231262207, + 1.1246769428253174 + ] + }, + { + "name": "SlateBoulder", + "vertices": 577, + "triangles": 1150, + "materials": [ + "StoneBlue", + "StoneLight", + "StoneShade" + ], + "colliderGuidance": "mesh on visible near rock only", + "boundsMin": [ + -1.131142497062683, + -0.735629141330719, + 0.0 + ], + "boundsMax": [ + 1.0828337669372559, + 0.7016701102256775, + 2.299999952316284 + ] + }, + { + "name": "TallWindTree", + "vertices": 1936, + "triangles": 3804, + "materials": [ + "BarkHoney", + "PineMint", + "LeafLight", + "LeafShade" + ], + "colliderGuidance": "mesh on visible near tree only; no enclosing canopy box", + "boundsMin": [ + -0.927472710609436, + -0.8348031044006348, + 0.0 + ], + "boundsMax": [ + 0.9894464015960693, + 0.8104627728462219, + 4.309138774871826 + ] + } + ], + "previewScenes": [ + "Preview_ClearMeadow", + "Preview_FlowerHills", + "Preview_StoneHills" + ], + "reviewRenders": [ + "Preview_ClearMeadow.png", + "Preview_FlowerHills.png", + "Preview_StoneHills.png", + "Catalog_TerrainDiscovery.png", + "Catalog_TreesShrubs.png", + "Catalog_FlowersGrass.png", + "Catalog_RockPlanes.png", + "Closeup_BranchesAndLeaves.png", + "Closeup_PetalsStemsLeaves.png", + "Closeup_OpenArchAndTerraces.png" + ], + "reviewState": { + "blender": "native source contracts and saved review renders; visual inspection recorded separately", + "userApproval": "PENDING", + "unityImport": "NOT_RUN", + "gameplayDiscovery": "NOT_RUN", + "mobilePerformance": "NOT_RUN" + }, + "files": [ + { + "path": "SourceAssets/Models/Meadow/MeadowCandidates.blend", + "bytes": 1350505, + "sha256": "2d95f1797c1a1042e32bb2588d0bde735b6e109a5f947b1f0720c7537bda6ea9" + }, + { + "path": "tools/models/build_meadow.py", + "bytes": 43245, + "sha256": "f0e7717a5154bf85eeff68639f6f21ca1f79f6d69af8fa0125acf43415f32db1" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DaisyColony.fbx", + "bytes": 179196, + "sha256": "a646a875d99b7cbcef727723b45c6a1e5d6231255ef100689fe69db2e55af63c" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/DistantRidge.fbx", + "bytes": 67788, + "sha256": "af68a4b08e0696cb0b56acf92bf0753d288ad6ba89fd4f19b183995f4ed6fd0c" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FarHorizon.fbx", + "bytes": 66924, + "sha256": "fcfb4a768ca33ab9ccd166f7e4b3a96cbc918d1a564f59144b0fab399cb7c179" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/FernCluster.fbx", + "bytes": 202508, + "sha256": "9153c91cb71a762c54bc0bc3c5e6fcfe2650185defaf60b5da75e25d78aa4911" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GentleHill.fbx", + "bytes": 67420, + "sha256": "356d3baa0927264f7807e13f3acb717b0c040ae09b23b8feac67e717142d3a19" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftGold.fbx", + "bytes": 72908, + "sha256": "0b2a3d68909b56ecdd3324503d3a6bc2b2f9dc8e1d173536dfe52ca96e49521f" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/GrassTuftMint.fbx", + "bytes": 72700, + "sha256": "650e02dab0ae3a0f05d036e2d58846cb97a93b77864cbc2c704b4364c26d9962" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LavenderLupineColony.fbx", + "bytes": 250284, + "sha256": "596266e001d7765634ff749deee33cbece1e5bb69ba693bf8c34da795f4f7bdb" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/LowLimestone.fbx", + "bytes": 47228, + "sha256": "026abf6db9395751e7ad933455bacc96a06fe331a4bb777001ee29bf33f8417d" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MeadowClearing.fbx", + "bytes": 68284, + "sha256": "78c307844de3ef5584aaa0bec3e57500f9115065dd643686473e52632d551a29" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MintShrub.fbx", + "bytes": 96012, + "sha256": "f501d302845a6df4814076abf76bdd97f5ffd2f0527f2aff236e0b97e78e62da" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/MossStoneSteps.fbx", + "bytes": 155068, + "sha256": "5a1bf51f80abde9a9a9e7a419ad30509ad62beac372244e0aba5681896a01739" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/OpenStoneArch.fbx", + "bytes": 34636, + "sha256": "8835e7c96ca842ddece1c1f7c9d168a1eabf4f0555e5a025bf46637969dff243" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PebbleScatter.fbx", + "bytes": 32060, + "sha256": "34748a549b40ebe8a53cb57d9638ccbf3e480e8b56453b686c378db8a069da8f" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/PinkCosmosColony.fbx", + "bytes": 153516, + "sha256": "6bfc69131ff2cde84f858aa50e3e11697679073d3cd1d0a39a15f4b02cc3afb6" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundCanopyTree.fbx", + "bytes": 147772, + "sha256": "9a41dcb9cb40e080bf4be6418f68eb5893561d9454e54a79a402f936ceb59757" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/RoundLimestone.fbx", + "bytes": 48700, + "sha256": "eaff76573039ab66927749569e511d5031e90c9ceb7dd8d7cccd84ab7032ca36" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageCanopyTree.fbx", + "bytes": 147772, + "sha256": "26665d557ef176a9fcce24107d19619b9848552538482eae4b35ebd9fbcd0aec" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SageShrub.fbx", + "bytes": 96108, + "sha256": "fbfed3947cc3e05c82f5660bae9c864576839174c7f72c0cbf0a278d6847eefc" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/SlateBoulder.fbx", + "bytes": 49852, + "sha256": "48da619e712ba4ee21c55baea8d93ce656db8a1c30475f0d2cb6bfecfc3b0a84" + }, + { + "path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow/TallWindTree.fbx", + "bytes": 116140, + "sha256": "378cdb0fc1d67a980d67d51a469148a6d720a03c9e650b35979bcf98ddf6bcad" + } + ] +} diff --git a/tools/models/build_meadow.py b/tools/models/build_meadow.py new file mode 100644 index 0000000..23bcfbb --- /dev/null +++ b/tools/models/build_meadow.py @@ -0,0 +1,756 @@ +"""Author the unadopted Meadow scenery candidate with Blender's native meshes. + +Blender --background --python tools/models/build_meadow.py -- --review-dir PATH +Blender --background --python tools/models/build_meadow.py -- --verify-only --review-dir PATH +No existing playground, scene, prefab, material, or stage asset is rewritten. +""" +import argparse +import hashlib +import json +import math +import random +import sys +from pathlib import Path + +import bpy +from mathutils import Vector +from mathutils.bvhtree import BVHTree + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / 'SourceAssets/Models/Meadow' +EXPORT = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Meadow' +DEFAULT_REVIEW = ROOT / 'artifacts/verification/VisualUpgrade/ModelReview/Meadow' +SETTINGS = dict(use_selection=True, object_types={'MESH'}, axis_forward='-Z', axis_up='Y', + apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL', + use_mesh_modifiers=True, bake_anim=False, add_leaf_bones=False, + path_mode='AUTO') +ASSETS = {} +MATERIALS = {} +RNG = random.Random(20517) + + +def material(name, color, roughness=.68): + srgb = color + color = tuple(c / 12.92 if c <= .04045 else ((c + .055) / 1.055) ** 2.4 for c in color) + mat = bpy.data.materials.new(name) + mat.diffuse_color = (*color, 1) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get('Principled BSDF') + bsdf.inputs['Base Color'].default_value = (*color, 1) + bsdf.inputs['Roughness'].default_value = roughness + mat['portableSurface'] = 'constant base color and roughness; no procedural texture dependency' + mat['authoredSRGB'] = list(srgb) + MATERIALS[name] = mat + return mat + + +def mesh(name, vertices, faces, mats, indices=None, smooth=True): + data = bpy.data.meshes.new(name + 'Mesh') + data.from_pydata(vertices, [], faces) + data.update() + obj = bpy.data.objects.new(name, data) + bpy.context.collection.objects.link(obj) + for mat in mats: + data.materials.append(MATERIALS[mat]) + uv = data.uv_layers.new(name='UVMap') + # Dominant-normal box projection: every face has usable, nonzero UV area. + for poly in data.polygons: + poly.use_smooth = smooth + poly.material_index = indices[poly.index] if indices else 0 + axis = max(range(3), key=lambda n: abs(poly.normal[n])) + a, b = [n for n in range(3) if n != axis] + for loop_index in poly.loop_indices: + point = data.vertices[data.loops[loop_index].vertex_index].co + uv.data[loop_index].uv = (point[a] * .25, point[b] * .25) + return obj + + +def tube(name, points, radii, mat='BarkHoney', sides=10): + verts, faces = [], [] + for i, p in enumerate(points): + direction = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(0, i - 1)]) + direction.normalize() + right = direction.cross(Vector((0, 1, 0))).normalized() + if right.length < .1: + right = direction.cross(Vector((1, 0, 0))).normalized() + up = direction.cross(right).normalized() + for j in range(sides): + a = math.tau * j / sides + q = Vector(p) + radii[i] * (right * math.cos(a) + up * math.sin(a)) + verts.append(tuple(q)) + faces.extend([tuple(reversed(range(sides))), tuple((len(points) - 1) * sides + j for j in range(sides))]) + for i in range(len(points) - 1): + for j in range(sides): + k = i * sides + j + next_k = i * sides + (j + 1) % sides + faces.append((k, next_k, next_k + sides, k + sides)) + return mesh(name, verts, faces, [mat]) + + +def leaf(name, start, direction, length, width, mat='LeafLight', bend=.14): + """Thick curved lance/oval leaf with an explicit raised midrib and tapered tip.""" + origin = Vector(start) + forward = Vector(direction).normalized() + side = forward.cross(Vector((0, 0, 1))).normalized() + if side.length < .1: + side = Vector((1, 0, 0)) + normal = side.cross(forward).normalized() + verts, faces = [], [] + for layer in (0, 1): + for i in range(9): + t = i / 8 + half = max(.007, width * math.sin(math.pi * t) ** .7) + center = origin + forward * length * t + normal * (bend * math.sin(math.pi * t) - layer * .022) + for s in (-1, 0, 1): + verts.append(tuple(center + side * s * half + normal * (.025 * (1 - abs(s)) * math.sin(math.pi * t)))) + for i in range(8): + for j in range(2): + k = i * 3 + j + faces.append((k, k + 3, k + 4, k + 1)) + faces.append((k + 27, k + 28, k + 31, k + 30)) + for j in (0, 2): + k = i * 3 + j + faces.append((k, k + 27, k + 30, k + 3) if j == 0 else (k, k + 3, k + 30, k + 27)) + faces.extend([(0, 1, 2, 29, 28, 27), (24, 51, 52, 53, 26, 25)]) + return mesh(name, verts, [tuple(reversed(f)) for f in faces], [mat]) + + +def organic(name, center, size, mats, seed=0, rock=False, segments=24, rings=12): + """Authored asymmetric section mesh; foliage lobes and rock planes have different profiles.""" + verts, faces, indices = [], [], [] + cx, cy, cz = center + sx, sy, sz = size + for k in range(rings + 1): + phi = math.pi * k / rings + ring_radius = max(.015, math.sin(phi)) + for j in range(segments): + theta = math.tau * j / segments + lobes = .10 * math.sin(5 * theta + seed + phi * 2) + .06 * math.cos(3 * theta - phi * 4) + shape = 1 + lobes * math.sin(phi) + if rock: + shape += .08 * math.sin(theta * 2 + seed) + z = math.cos(phi) + if rock: + z = math.copysign(abs(z) ** .70, z) + verts.append((cx + sx * ring_radius * math.cos(theta) * shape + .13 * sx * math.sin(phi * 2), + cy + sy * ring_radius * math.sin(theta) * shape, + cz + sz * z * (1 + .035 * math.sin(theta * 3 + seed) * math.sin(phi)))) + for k in range(rings): + for j in range(segments): + faces.append((k * segments + j, k * segments + (j + 1) % segments, + (k + 1) * segments + (j + 1) % segments, (k + 1) * segments + j)) + # Broad intentional colored planes, rather than isolated checkerboard faces. + band = (j + seed) % segments + accent = (1 if band < segments // 4 and k < rings * .6 else (2 if band > segments * .72 and k > rings * .3 else 0)) if rock else 0 + indices.append(min(len(mats) - 1, accent)) + faces.extend([tuple(reversed(range(segments))), tuple(rings * segments + j for j in range(segments))]) + indices.extend([0, 0]) + return mesh(name, verts, [tuple(reversed(f)) for f in faces], mats, indices, smooth=not rock) + + +def terrain(name, rx, ry, height, hill=False): + verts = [(0, 0, height if hill else .14)] + segments, rings = 64, 16 + for k in range(1, rings + 1): + r = k / rings + for j in range(segments): + a = math.tau * j / segments + warp = 1 + .045 * math.sin(3 * a) + .025 * math.sin(7 * a + 1) + z = height * (math.cos(r * math.pi / 2) ** 2) if hill else .14 + .035 * math.sin(a * 3) * r ** 3 + verts.append((rx * r * math.cos(a) * warp, ry * r * math.sin(a) * warp, max(.015, z))) + faces, indices = [], [] + for j in range(segments): + faces.append((0, 1 + j, 1 + (j + 1) % segments)) + indices.append(0) + for k in range(rings - 1): + for j in range(segments): + p = 1 + k * segments + j + q = 1 + k * segments + (j + 1) % segments + faces.append((p, p + segments, q + segments, q)) + indices.append(0) + bottom = len(verts) + verts.extend([(verts[1 + (rings - 1) * segments + j][0], verts[1 + (rings - 1) * segments + j][1], 0) for j in range(segments)]) + for j in range(segments): + p = 1 + (rings - 1) * segments + j + q = 1 + (rings - 1) * segments + (j + 1) % segments + faces.append((p, bottom + j, bottom + (j + 1) % segments, q)) + indices.append(2) + faces.append(tuple(reversed(range(bottom, bottom + segments)))) + indices.append(2) + return mesh(name, verts, faces, ['GroundMint', 'GroundSage', 'SoilWarm'], indices) + + +def finish(name, parts, role, collider='none'): + bpy.ops.object.select_all(action='DESELECT') + for obj in parts: + obj.select_set(True) + bpy.context.view_layer.objects.active = parts[0] + if len(parts) > 1: + bpy.ops.object.join() + obj = bpy.context.object + obj.name = name + # FBX consolidates identical material pointers: make the native slot contract + # equally explicit before export instead of treating consolidation as loss. + old_materials = list(obj.data.materials) + unique_materials = list(dict.fromkeys(old_materials)) + remap = [unique_materials.index(m) for m in old_materials] + indices = [remap[p.material_index] for p in obj.data.polygons] + obj.data.materials.clear() + for mat in unique_materials: + obj.data.materials.append(mat) + for polygon, index in zip(obj.data.polygons, indices): + polygon.material_index = index + min_z = min(v.co.z for v in obj.data.vertices) + for vertex in obj.data.vertices: + vertex.co.z -= min_z + bpy.context.scene.cursor.location = (0, 0, 0) + bpy.ops.object.origin_set(type='ORIGIN_CURSOR') + obj['role'] = role + obj['colliderGuidance'] = collider + obj['candidateStatus'] = 'USER_REVIEW_REQUIRED_NOT_ADOPTED' + obj['nativeGeometry'] = 'custom section surfaces, curved tubes, thick curved leaves; no external meshes' + obj.data.update() + ASSETS[name] = obj + return obj + + +def tree(name, kind): + parts = [] + if kind == 'tall': + parts.append(tube('TaperedTrunk', [(0, 0, 0), (.08, 0, 1), (-.1, .08, 2.2), (.03, 0, 3.8)], [.24, .18, .10, .025])) + for i, (z, radius) in enumerate([(1.45, .87), (2.15, .76), (2.85, .58), (3.45, .36)]): + parts.append(organic('SweptCrownTier', (0, 0, z), (radius, radius * .76, .84), ['PineMint', 'LeafLight', 'LeafShade'], i)) + for i in range(12): + a = i * 2.4 + z = 1.5 + (i % 4) * .46 + parts.append(leaf('CrownTip', (.36 * math.cos(a), .3 * math.sin(a), z), (math.cos(a), math.sin(a), .4), .57, .12, 'LeafLight')) + else: + parts.append(tube('BentTrunk', [(0, 0, 0), (-.13, .03, .6), (-.03, 0, 1.25), (.22, .06, 2.15), (.35, .12, 2.7)], [.35, .27, .21, .13, .055])) + for j, (end, radius) in enumerate([((-1, .05, 2.45), .87), ((1.08, .1, 2.8), .92), ((.1, .70, 3.15), .93), ((-.45, -.52, 2.95), .80)]): + parts.append(tube('VisibleBranch', [(-.04, 0, 1.25), (end[0] * .5, end[1] * .5, 1.85), end], [.13, .09, .025])) + palette = ['LeafMint', 'LeafLight', 'LeafShade'] if kind == 'round' else ['LeafSage', 'LeafLight', 'LeafShade'] + if j == 2: + palette[0] = 'LeafLight' + parts.append(organic('SculptedCanopy', end, (radius, radius * .82, radius * .77), palette, j + 4)) + # Root flares and long bark ridges keep the trunk readable in a closeup. + for j in range(5): + a = math.tau * j / 5 + parts.append(tube('RootFlare', [(.59 * math.cos(a), .5 * math.sin(a), .05), (.27 * math.cos(a), .25 * math.sin(a), .20), (0, 0, .55)], [.035, .10, .09])) + for j in range(3): + a = j * 2.1 + parts.append(tube('BarkRibbon', [(.29 * math.cos(a), .29 * math.sin(a), .2), (-.12 + .22 * math.cos(a), .23 * math.sin(a), .7), (.18 * math.cos(a), .18 * math.sin(a), 1.3)], [.012, .018, .012], 'BarkLight', 6)) + for j in range(16): + a = j * 2.4 + start = (1.06 * math.cos(a), .83 * math.sin(a), 2.7 + .25 * math.sin(j)) + parts.append(leaf('CanopyLeafAccent', start, (.35 * math.cos(a), .35 * math.sin(a), .35), .40, .13, 'LeafLight')) + return finish(name, parts, 'near tree with visible branched trunk and separate foliage', 'mesh on visible near tree only; no enclosing canopy box') + + +def flower_cluster(name, variety, count, seed): + rng = random.Random(seed) + parts = [] + for n in range(count): + x, y = rng.uniform(-.58, .58), rng.uniform(-.4, .4) + h = rng.uniform(.54, .92) if variety != 'spike' else rng.uniform(.85, 1.22) + tip = Vector((x + .09, y, h)) + parts.append(tube('CurvedStem', [(x, y, .025), (x - .035, y, h * .4), tuple(tip)], [.023, .019, .012], 'Stem', 7)) + for j in range(2): + a = n * 1.7 + j * math.pi + parts.append(leaf('StemLeaf', (x, y, h * (.28 + j * .19)), (math.cos(a), math.sin(a), .25), .32, .085, 'LeafSage')) + if variety == 'spike': + for row in range(5): + for j in range(4): + a = math.tau * j / 4 + row * .6 + base = tip + Vector((0, 0, -.37 + row * .085)) + parts.append(leaf('LupineFloret', tuple(base), (math.cos(a), math.sin(a), .65), .16 - row * .016, .063, 'PetalLavender', .05)) + else: + petal_mat = 'PetalCream' if variety == 'daisy' else 'PetalPink' + petals = 8 if variety == 'daisy' else 6 + for j in range(petals): + a = math.tau * j / petals + n * .3 + parts.append(leaf('SpoonPetal', tuple(tip), (math.cos(a), math.sin(a), .10), .25, .10, petal_mat, .09)) + parts.append(organic('RecessedFlowerCenter', tuple(tip + Vector((0, 0, .025))), (.079, .079, .036), ['PollenHoney'], n, segments=12, rings=6)) + return finish(name, parts, 'flower colony with petals, stems, leaves; no faces', 'none: small decorative vegetation') + + +def build_assets(): + palette = { + 'GroundMint': (.53, .74, .50), 'GroundSage': (.59, .77, .49), 'SoilWarm': (.52, .42, .29), + 'LeafMint': (.45, .67, .44), 'LeafSage': (.53, .69, .41), 'LeafLight': (.62, .77, .46), + 'LeafShade': (.31, .56, .45), 'PineMint': (.38, .66, .55), 'BarkHoney': (.52, .36, .21), + 'BarkLight': (.68, .49, .30), 'Stem': (.32, .52, .27), 'GrassMint': (.49, .73, .46), + 'GrassGold': (.69, .80, .46), 'PetalCream': (.98, .88, .59), 'PetalPink': (.90, .43, .56), + 'PetalLavender': (.54, .42, .77), 'PollenHoney': (.94, .58, .13), + 'StoneCream': (.59, .64, .57), 'StoneLight': (.75, .76, .65), 'StoneShade': (.42, .53, .49), + 'StoneBlue': (.58, .70, .71), 'PathSand': (.76, .77, .57), 'RidgeTeal': (.54, .73, .72), + 'RidgeLight': (.61, .78, .74), 'RidgeFar': (.66, .82, .83), + } + for name, color in palette.items(): + material(name, color, .78 if name.startswith('Stone') else .67) + finish('MeadowClearing', [terrain('OpenPlayGround', 6.2, 5.0, .15)], 'flat playable clearing; arrange outside existing sphere positions', 'future gameplay floor only; top .14m authored, placement must align existing floor') + finish('GentleHill', [terrain('SoftSlopingLand', 5.5, 4.3, 1.55, True)], 'surrounding rolling terrain', 'none: peripheral scenery') + ridge = terrain('DistantRollingRidge', 12, 6, 4.0, True) + ridge.data.materials[0] = MATERIALS['RidgeTeal'] + ridge.data.materials[1] = MATERIALS['RidgeLight'] + finish('DistantRidge', [ridge], 'far terrain; atmospheric teal', 'none: distant decoration') + far = terrain('FarHorizon', 15, 7, 5.0, True) + far.data.materials[0] = MATERIALS['RidgeFar'] + far.data.materials[1] = MATERIALS['RidgeFar'] + finish('FarHorizon', [far], 'distant skyline terrain', 'none: distant decoration') + tree('RoundCanopyTree', 'round') + tree('SageCanopyTree', 'sage') + tree('TallWindTree', 'tall') + for name, color, seed in [('SageShrub', 'LeafSage', 8), ('MintShrub', 'LeafMint', 16)]: + parts = [] + for n in range(4): + a = n * 2.4 + end = (.35 * math.cos(a), .3 * math.sin(a), .52 + (n % 2) * .18) + parts.append(tube('ShrubWoodyBranch', [(0, 0, 0), (end[0] * .5, end[1] * .5, .35), end], [.085, .045, .015], 'BarkHoney', 7)) + parts.append(organic('ShrubLeafMass', end, (.46, .36, .39), [color, 'LeafLight', 'LeafShade'], seed + n, segments=18, rings=10)) + for n in range(12): + a = n * 2.4 + parts.append(leaf('ReadableShrubLeaf', (.40 * math.cos(a), .35 * math.sin(a), .58), (math.cos(a), math.sin(a), .3), .27, .10, 'LeafLight')) + finish(name, parts, 'branched near shrub', 'none: small decorative vegetation') + fern_parts = [] + for n in range(7): + a = n * math.tau / 7 + direction = Vector((math.cos(a), math.sin(a), .55)) + for j in range(5): + p = direction * (.10 + j * .12) + for side in (-1, 1): + sideways = Vector((-math.sin(a) * side, math.cos(a) * side, .15)) + fern_parts.append(leaf('FernLeaflet', tuple(p), tuple(direction * .3 + sideways), .20 - .019 * j, .060, 'LeafSage')) + fern_parts.append(tube('FernSpine', [(0, 0, .02), tuple(direction * .30), tuple(direction * .70)], [.016, .011, .005], 'Stem', 6)) + finish('FernCluster', fern_parts, 'individual curved fern fronds and leaflets', 'none: small decorative vegetation') + for name, seed in [('GrassTuftMint', 1), ('GrassTuftGold', 2)]: + rng = random.Random(seed) + parts = [] + for n in range(19): + a = rng.random() * math.tau + length = rng.uniform(.21, .48) + parts.append(leaf('CurvedGrassBlade', (rng.uniform(-.28, .28), rng.uniform(-.22, .22), .025), + (.55 * math.cos(a), .55 * math.sin(a), 1), length, rng.uniform(.02, .043), + 'GrassMint' if n % 3 else 'GrassGold', .04)) + finish(name, parts, 'small grass colony with tapered curved blades', 'none: small decorative vegetation') + flower_cluster('DaisyColony', 'daisy', 5, 11) + flower_cluster('PinkCosmosColony', 'cosmos', 5, 21) + flower_cluster('LavenderLupineColony', 'spike', 4, 31) + for name, size, seed, blue in [('RoundLimestone', (1.12, .78, .73), 3, False), ('SlateBoulder', (1.0, .72, 1.15), 7, True), + ('LowLimestone', (.72, .52, .32), 9, False)]: + obj = organic('CarvedStone', (0, 0, size[2]), size, ['StoneBlue' if blue else 'StoneCream', 'StoneLight', 'StoneShade'], seed, rock=True, segments=16, rings=10) + bevel = obj.modifiers.new('SoftStoneEdges', 'BEVEL') + bevel.width = .035 + bevel.segments = 2 + bevel.angle_limit = .35 + bpy.context.view_layer.objects.active = obj + bpy.ops.object.modifier_apply(modifier=bevel.name) + finish(name, [obj], 'soft rock silhouette with readable broad planes', 'mesh on visible near rock only') + pebbles = [] + for n, (x, y, s) in enumerate([(-.36, -.10, .22), (.20, .14, .29), (.46, -.14, .15), (-.06, -.25, .12)]): + pebbles.append(organic('RoundedPebble', (x, y, s * .43), (s, s * .75, s * .43), ['StoneCream', 'StoneLight'], n, rock=True, segments=12, rings=6)) + finish('PebbleScatter', pebbles, 'small pebble scatter', 'none: small distant or ground decoration') + # One continuous tunnel surface, with an open center and non-box collider contract. + points, radii = [], [] + for i in range(33): + a = math.pi - math.pi * i / 32 + points.append((1.18 * math.cos(a), 0, .04 + 1.72 * math.sin(a))) + radii.append(.30 + .035 * math.sin(i * .8)) + arch = tube('OpenStoneTunnel', points, radii, 'StoneCream', 12) + arch.data.materials.append(MATERIALS['StoneLight']) + arch.data.materials.append(MATERIALS['StoneShade']) + for face in arch.data.polygons: + face.material_index = 0 + finish('OpenStoneArch', [arch], 'natural stone discovery arch with a physically open hole', 'nonconvex visible mesh collider; NEVER enclosing BoxCollider; do not ignore front raycast hits') + parts = [] + for n in range(3): + slab = terrain('StoneTerrace', .76, .57, .15) + for v in slab.data.vertices: + v.co.x += (n - 1) * .76 + v.co.z = v.co.z * (1.5 + n * 1.6) + for j in range(3): + slab.data.materials[j] = MATERIALS[['StoneCream', 'StoneLight', 'StoneShade'][j]] + parts.append(slab) + finish('MossStoneSteps', parts, 'three rounded stone terraces retaining stage discovery height differences', 'nonconvex mesh matching terraces; integration must preserve existing sphere positions') + + +def scene_setup(name): + scene = bpy.data.scenes.new(name) + scene.unit_settings.system = 'METRIC' + scene.unit_settings.scale_length = 1 + scene.render.engine = 'CYCLES' + scene.cycles.samples = 32 + scene.cycles.use_denoising = True + preferences = bpy.context.preferences.addons['cycles'].preferences + preferences.compute_device_type = 'OPTIX' + preferences.get_devices() + for device in preferences.devices: + device.use = device.type == 'OPTIX' + assert any(d.use and d.type == 'OPTIX' for d in preferences.devices), 'Expected coordinated RTX OPTIX render device' + scene.cycles.device = 'GPU' + scene.render.resolution_x = 1600 + scene.render.resolution_y = 900 + scene.render.resolution_percentage = 100 + scene.render.image_settings.file_format = 'PNG' + scene.view_settings.view_transform = 'AgX' + scene.view_settings.look = 'AgX - Medium High Contrast' + world = bpy.data.worlds.new(name + '_CyanDay') + world.use_nodes = True + world.node_tree.nodes['Background'].inputs['Color'].default_value = (.40, .76, .85, 1) + world.node_tree.nodes['Background'].inputs['Strength'].default_value = .45 + # Lighting stays physical; the visible cyan sky keeps its bright art direction. + nodes, links = world.node_tree.nodes, world.node_tree.links + sky = nodes.new('ShaderNodeBackground') + sky.name = 'VisibleCyanSky' + sky.inputs['Color'].default_value = (.12, .67, .85, 1) + sky.inputs['Strength'].default_value = 1 + ray = nodes.new('ShaderNodeLightPath') + mix = nodes.new('ShaderNodeMixShader') + links.new(ray.outputs['Is Camera Ray'], mix.inputs[0]) + links.new(nodes['Background'].outputs[0], mix.inputs[1]) + links.new(sky.outputs[0], mix.inputs[2]) + links.new(mix.outputs[0], nodes['World Output'].inputs['Surface']) + scene.world = world + for label, light_type, location, energy, size, color in [ + ('WarmSoftSun', 'AREA', (-10, -8, 18), 3800, 10, (1.0, .89, .72)), + ('SkyFill', 'AREA', (8, -1, 12), 2200, 14, (.76, .91, 1.0)), + ]: + data = bpy.data.lights.new(label, light_type) + data.energy, data.shape, data.size, data.color = energy, 'DISK', size, color + obj = bpy.data.objects.new(label, data) + scene.collection.objects.link(obj) + obj.location = location + obj.rotation_euler = (Vector((0, 3, 0)) - obj.location).to_track_quat('-Z', 'Y').to_euler() + sun = bpy.data.lights.new('Daylight', 'SUN') + sun.energy, sun.angle, sun.color = 2.0, .18, (1.0, .92, .79) + obj = bpy.data.objects.new('Daylight', sun) + scene.collection.objects.link(obj) + obj.rotation_euler = (.42, -.55, -.45) + return scene + + +def camera(scene, position, target, lens=44, ortho=None): + data = bpy.data.cameras.new(scene.name + '_ReviewCamera') + obj = bpy.data.objects.new(data.name, data) + scene.collection.objects.link(obj) + obj.location = position + obj.rotation_euler = (Vector(target) - obj.location).to_track_quat('-Z', 'Y').to_euler() + data.lens = lens + data.clip_end = 250 + if ortho: + data.type, data.ortho_scale = 'ORTHO', ortho + scene.camera = obj + scene['cameraPurpose'] = 'slightly elevated three-quarter puzzle-compatible review; does not change game camera' + + +def instance(scene, name, loc, scale=1, rotation=0): + obj = ASSETS[name].copy() + obj.data = ASSETS[name].data + obj.name = name + '_SetDress' + scene.collection.objects.link(obj) + obj.location = loc + obj.scale = (scale,) * 3 if isinstance(scale, (float, int)) else scale + obj.rotation_euler.z = rotation + return obj + + +def landscape(name, theme): + scene = scene_setup(name) + scene['themeRuntimeIdentifier'] = ['Garden', 'Cloud', 'Workshop'][theme] + scene['stageRange'] = ['01-10', '11-20', '21-30'][theme] + scene['layoutStatus'] = 'concept preview only; original sphere positions and discoveries must be preserved during later integration' + # Continuous terrain continues behind the play area and to the horizon. + instance(scene, 'MeadowClearing', (0, 8, -.14), (9, 8, 1)) + for i in range(8): + a = math.pi * .04 + math.pi * .92 * i / 7 + instance(scene, 'DistantRidge', (math.cos(a) * 33, 25 + math.sin(a) * 18, -.2), 1.1 + .15 * math.sin(i), i * .7) + for i in range(6): + instance(scene, 'FarHorizon', (-44 + i * 17, 57 + (i % 2) * 4, -1.5), 1.3, i * .4) + for x, y, s in [(-10, 9, 1.2), (10, 13, 1.3), (-14, 19, 1.6), (18, 20, 1.5), (0, 22, 1.8)]: + instance(scene, 'GentleHill', (x, y, -.05), s) + if theme == 1: + for x, y in [(-8, 4), (9, 8), (-4, 13)]: + instance(scene, 'GentleHill', (x, y, -.03), .9) + positions = [(-7.6, 1, 1.3), (7.6, 5, 1.25), (-10, 13, 1.5), (11, 16, 1.55), (-4, 19, 1.4), (5, 24, 1.4), (-16, 21, 1.2), (20, 25, 1.8)] + for i, (x, y, s) in enumerate(positions): + instance(scene, ['RoundCanopyTree', 'SageCanopyTree', 'TallWindTree'][(i + theme) % 3], (x, y, .03), s, i * .9) + for i in range(24): + a = i * math.tau / 24 + x, y = 8.4 * math.cos(a), 4 + 8 * math.sin(a) + if -4 < y < 2 and abs(x) < 6: + continue + instance(scene, 'MintShrub' if i % 2 else 'SageShrub', (x, y, .02), .7 + (i % 3) * .13, a) + # Irregular colonies, no luminous spherical decorations or face plants. + rng = random.Random(120 + theme) + for i in range(145 if theme == 1 else 100): + x, y = rng.uniform(-15, 15), rng.uniform(-9, 24) + if (x / 6.1) ** 2 + (y / 4.9) ** 2 < 1.1: + continue + obj_name = ['GrassTuftMint', 'GrassTuftGold', 'FernCluster'][i % 3] + instance(scene, obj_name, (x, y, .015), rng.uniform(.7, 1.25), rng.random() * math.tau) + if i % (2 if theme == 1 else 6) == 0: + flower_name = ['DaisyColony', 'PinkCosmosColony', 'LavenderLupineColony'][i % 3] + instance(scene, flower_name, (x + .45, y + .2, .02), rng.uniform(.7, 1.2), rng.random() * math.tau) + for i in range(14 if theme == 2 else 6): + a = i * 2.4 + r = 7 + (i % 3) * 2.2 + x, y = r * math.cos(a), 4 + r * math.sin(a) + instance(scene, ['RoundLimestone', 'SlateBoulder', 'LowLimestone'][i % 3], (x, y, .02), 1.0 + (.6 if theme == 2 else .1) * (i % 2), a) + instance(scene, 'PebbleScatter', (x + .65, y - .75, .02), .9, -a) + instance(scene, 'OpenStoneArch', (-5.5, 5.8, .03), 1.15, -.22) + instance(scene, 'MossStoneSteps', (5.0, 3.6, .03), 1.25, .36) + scene.view_layers[0].update() + ground_names = {'MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon'} + grounds = [o for o in scene.objects if o.get('role') and any(o.name.startswith(n + '_SetDress') for n in ground_names)] + terrain_bvhs = {g: BVHTree.FromPolygons([v.co for v in g.data.vertices], [list(p.vertices) for p in g.data.polygons]) for g in grounds} + for obj in list(scene.objects): + if obj.type != 'MESH' or obj in grounds: + continue + x, y = obj.location.x, obj.location.y + high = Vector((x, y, 40)) + ground_z = 0 + for ground in grounds: + inverse = ground.matrix_world.inverted() + point, _, _, _ = terrain_bvhs[ground].ray_cast(inverse @ high, (inverse.to_3x3() @ Vector((0, 0, -1))).normalized()) + if point is not None: + ground_z = max(ground_z, (ground.matrix_world @ point).z) + obj.location.z = ground_z + .008 + camera(scene, (17, -25, 16), (0, 7, 1.8), lens=34) + return scene + + +def catalog_scene(name, assets): + scene = scene_setup(name) + for i, asset_name in enumerate(assets): + obj = ASSETS[asset_name] + width = max(obj.dimensions.x, obj.dimensions.y) + scale = min(2.6, 2.8 / max(width, .1)) + x, y = (i % 3 - 1) * 4.8, (i // 3) * 5.2 + instance(scene, asset_name, (x, y, 0), scale) + data = bpy.data.curves.new(asset_name + '_Label', 'FONT') + data.body, data.size, data.align_x = asset_name, .23, 'CENTER' + data.extrude = .002 + data.materials.append(MATERIALS['BarkHoney']) + label = bpy.data.objects.new(data.name, data) + scene.collection.objects.link(label) + label.location = (x, y - 1.8, .03) + instance(scene, 'MeadowClearing', (0, 4, -.14), (4, 4, 1)) + camera(scene, (12, -20, 19), (0, 2.5, .85), ortho=18) + return scene + + +def closeup_scene(name, asset_names, positions, target, ortho): + scene = scene_setup(name) + for asset_name, position in zip(asset_names, positions): + instance(scene, asset_name, position) + instance(scene, 'MeadowClearing', (0, 0, -.14), 3) + camera(scene, (7, -12, 8), target, ortho=ortho) + return scene + + +def validate_source(): + result = [] + candidates = [o for o in bpy.data.objects if o.get('candidateStatus') and not o.name.endswith('_SetDress') and o.name in ASSETS] + assert len(candidates) == 21, f'Expected 21 authored candidate assets, found {len(candidates)}' + for obj in candidates: + assert obj.type == 'MESH' and obj.data.polygons, obj.name + assert obj.location.length < 1e-6, f'Nonzero native pivot: {obj.name}' + assert all(abs(s - 1) < 1e-6 for s in obj.scale), obj.name + assert abs(min(v.co.z for v in obj.data.vertices)) < .0001, f'Not floor pivot: {obj.name}' + assert all(math.isfinite(c) for v in obj.data.vertices for c in v.co), obj.name + assert obj.data.uv_layers.active and len(obj.data.uv_layers.active.data) == len(obj.data.loops), obj.name + assert all(math.isfinite(c) for loop in obj.data.uv_layers.active.data for c in loop.uv), obj.name + assert all(poly.area > 1e-10 for poly in obj.data.polygons), f'Degenerate face: {obj.name}' + if obj.name in {'MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon'}: + top = [p for p in obj.data.polygons if p.index < 1024] + assert all(p.normal.z > 0 for p in top), f'Inverted terrain top normals: {obj.name}' + assert obj.data.materials and all(m and m.use_nodes for m in obj.data.materials), obj.name + assert all(m.node_tree.nodes.get('Principled BSDF') for m in obj.data.materials), obj.name + triangles = sum(len(p.vertices) - 2 for p in obj.data.polygons) + assert triangles <= 16000, f'Excessive mobile candidate mesh: {obj.name} ({triangles})' + result.append(dict(name=obj.name, vertices=len(obj.data.vertices), triangles=triangles, + materials=[m.name for m in obj.data.materials], colliderGuidance=obj['colliderGuidance'], + boundsMin=[min(v.co[i] for v in obj.data.vertices) for i in range(3)], + boundsMax=[max(v.co[i] for v in obj.data.vertices) for i in range(3)])) + arch = bpy.data.objects['OpenStoneArch'] + bvh = BVHTree.FromPolygons([v.co for v in arch.data.vertices], [list(p.vertices) for p in arch.data.polygons]) + assert bvh.ray_cast(Vector((0, -2, .8)), Vector((0, 1, 0)), 4)[0] is None, 'Arch center hole blocked' + assert bvh.ray_cast(Vector((1.18, -2, .3)), Vector((0, 1, 0)), 4)[0] is not None, 'Arch side must occlude' + assert all(name in bpy.data.scenes for name in ['Preview_ClearMeadow', 'Preview_FlowerHills', 'Preview_StoneHills']), 'Missing assembled landscape previews' + return result + + +def verify_fbx_roundtrip(names): + """Read the actual exported bytes; source mesh checks alone cannot validate FBX.""" + scene = bpy.data.scenes.new('TemporaryFBXVerification') + bpy.context.window.scene = scene + results = [] + for name in names: + previous = set(bpy.data.objects) + bpy.ops.import_scene.fbx(filepath=str(EXPORT / (name + '.fbx'))) + imported = set(bpy.data.objects) - previous + imported_meshes = [o for o in imported if o.type == 'MESH'] + assert len(imported_meshes) == 1, f'FBX must contain one mesh: {name}' + obj = imported_meshes[0] + native = ASSETS[name] + points = [obj.matrix_world @ v.co for v in obj.data.vertices] + for axis in range(3): + expected_min = min(v.co[axis] for v in native.data.vertices) + expected_max = max(v.co[axis] for v in native.data.vertices) + assert abs(min(p[axis] for p in points) - expected_min) < .005, f'FBX axis/pivot min mismatch: {name}' + assert abs(max(p[axis] for p in points) - expected_max) < .005, f'FBX axis/scale max mismatch: {name}' + assert obj.data.uv_layers.active, f'FBX UV lost: {name}' + assert len(obj.data.materials) == len(native.data.materials), f'FBX material slots lost: {name}' + results.append(dict(asset=name, importedMeshCount=1, worldBoundsMatchNative=True, + uvPresent=True, materialSlots=len(obj.data.materials))) + for item in imported: + bpy.data.objects.remove(item, do_unlink=True) + bpy.data.scenes.remove(scene) + bpy.context.window.scene = bpy.data.scenes['Preview_ClearMeadow'] + return results + + +def file_record(path): + return dict(path=path.relative_to(ROOT).as_posix(), bytes=path.stat().st_size, + sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + + +def organize_collections(library, scenes): + terrain_names = {'MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon'} + tree_names = {'RoundCanopyTree', 'SageCanopyTree', 'TallWindTree'} + stone_names = {'RoundLimestone', 'SlateBoulder', 'LowLimestone', 'PebbleScatter', 'OpenStoneArch', 'MossStoneSteps'} + for scene in [library] + scenes: + groups = {} + for obj in list(scene.objects): + name = obj.name.split('_SetDress')[0] + group = ('01_Terrain' if name in terrain_names else '02_Trees' if name in tree_names else + '04_StoneDiscovery' if name in stone_names else '03_SmallVegetation' if obj.type == 'MESH' else '05_LightingCameraLabels') + if group not in groups: + collection = bpy.data.collections.new(scene.name + '_' + group) + scene.collection.children.link(collection) + groups[group] = collection + groups[group].objects.link(obj) + for collection in list(obj.users_collection): + if collection != groups[group]: + collection.objects.unlink(obj) + + +def verify_existing(review): + blend = SOURCE / 'MeadowCandidates.blend' + assert blend.is_file(), f'Missing native source: {blend}' + manifest_path = SOURCE / 'provenance.json' + assert manifest_path.is_file(), f'Missing provenance: {manifest_path}' + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + for record in manifest['files']: + path = ROOT / record['path'] + assert path.is_file(), f'Missing resource: {path}' + assert path.stat().st_size == record['bytes'], f'Byte mismatch: {path}' + assert hashlib.sha256(path.read_bytes()).hexdigest() == record['sha256'], f'Hash mismatch: {path}' + bpy.ops.wm.open_mainfile(filepath=str(blend)) + for obj in bpy.data.objects: + if obj.name in manifest['assetNames']: + ASSETS[obj.name] = obj + meshes = validate_source() + for name in manifest['assetNames']: + path = EXPORT / (name + '.fbx') + assert path.is_file() and path.stat().st_size > 8000, f'Missing/empty export: {path}' + for name in manifest['reviewRenders']: + path = review / name + assert path.is_file() and path.stat().st_size > 10000, f'Missing review render: {path}' + roundtrip = verify_fbx_roundtrip(manifest['assetNames']) + report = dict(status='PASS', sourceReopened=True, meshContracts=meshes, fbxRoundtrip=roundtrip, + resourcesAndHashesChecked=len(manifest['files']), + archHoleRayPass=True, archSideRayOccludes=True, + unityImportAndGameplay='NOT_RUN; user candidate review required') + (review / 'source-verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8') + print('MEADOW_VERIFY_PASS ' + json.dumps(dict(assets=len(meshes), sourceReopened=True, resources=len(manifest['files'])))) + + +def main(): + argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else [] + parser = argparse.ArgumentParser() + parser.add_argument('--review-dir', type=Path, default=DEFAULT_REVIEW) + parser.add_argument('--verify-only', action='store_true') + args = parser.parse_args(argv) + review = args.review_dir.resolve() + assert not any(p.resolve() == review or p.resolve() in review.parents for p in [SOURCE, EXPORT]), 'Review renders must not overwrite authored source or FBX export folders' + review.mkdir(parents=True, exist_ok=True) + if args.verify_only: + verify_existing(review) + return + SOURCE.mkdir(parents=True, exist_ok=True) + EXPORT.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.read_factory_settings(use_empty=True) + library = bpy.context.scene + library.name = 'AssetLibrary_21NativeCandidates' + library.unit_settings.system = 'METRIC' + library.unit_settings.scale_length = 1 + collection = bpy.data.collections.new('Meadow_NativeAssetLibrary') + library.collection.children.link(collection) + bpy.context.view_layer.active_layer_collection = bpy.context.view_layer.layer_collection.children[collection.name] + build_assets() + previews = [landscape('Preview_ClearMeadow', 0), landscape('Preview_FlowerHills', 1), landscape('Preview_StoneHills', 2)] + catalogs = [ + catalog_scene('Catalog_TerrainDiscovery', ['MeadowClearing', 'GentleHill', 'DistantRidge', 'FarHorizon', 'OpenStoneArch', 'MossStoneSteps']), + catalog_scene('Catalog_TreesShrubs', ['RoundCanopyTree', 'SageCanopyTree', 'TallWindTree', 'MintShrub', 'SageShrub', 'FernCluster']), + catalog_scene('Catalog_FlowersGrass', ['DaisyColony', 'PinkCosmosColony', 'LavenderLupineColony', 'GrassTuftMint', 'GrassTuftGold']), + catalog_scene('Catalog_RockPlanes', ['RoundLimestone', 'SlateBoulder', 'LowLimestone', 'PebbleScatter']), + closeup_scene('Closeup_BranchesAndLeaves', ['RoundCanopyTree', 'FernCluster', 'SageShrub'], [(0, 0, 0), (-1.4, -.9, 0), (1.6, .6, 0)], (0, 0, 1.95), 9.6), + closeup_scene('Closeup_PetalsStemsLeaves', ['DaisyColony', 'PinkCosmosColony', 'LavenderLupineColony'], [(-1, 0, 0), (.5, .15, 0), (1.6, .5, 0)], (.2, 0, .6), 4.4), + closeup_scene('Closeup_OpenArchAndTerraces', ['OpenStoneArch', 'MossStoneSteps', 'SlateBoulder'], [(-1.5, .4, 0), (1.5, 0, 0), (2.1, 1.5, 0)], (0, .4, .8), 8), + ] + mesh_report = validate_source() + organize_collections(library, previews + catalogs) + bpy.context.window.scene = library + for name, obj in ASSETS.items(): + bpy.ops.object.select_all(action='DESELECT') + obj.select_set(True) + bpy.context.view_layer.objects.active = obj + bpy.ops.export_scene.fbx(filepath=str(EXPORT / (name + '.fbx')), **SETTINGS) + print('EXPORTED ' + name) + # Asset library objects stay at their native pivots. Separate linked preview scenes + # provide layout without sacrificing reusable asset transforms. + bpy.context.window.scene = previews[0] + bpy.context.preferences.filepaths.save_version = 0 + blend = SOURCE / 'MeadowCandidates.blend' + bpy.ops.wm.save_as_mainfile(filepath=str(blend)) + render_names, render_records = [], [] + for scene in previews + catalogs: + filename = scene.name + '.png' + scene.render.filepath = str(review / filename) + bpy.ops.render.render(write_still=True, scene=scene.name) + assert (review / filename).is_file(), f'Render not saved: {filename}' + render_names.append(filename) + render_records.append(dict(file=filename, scene=scene.name, frame=scene.frame_current, + source=file_record(blend), camera=scene.camera.name, + cameraLocation=list(scene.camera.location), cameraEuler=list(scene.camera.rotation_euler), + cameraType=scene.camera.data.type, lens=scene.camera.data.lens, + orthoScale=scene.camera.data.ortho_scale, + resolution=[scene.render.resolution_x, scene.render.resolution_y], + engine=scene.render.engine, device=scene.cycles.device, + samples=scene.cycles.samples, denoise=scene.cycles.use_denoising, + bytes=(review / filename).stat().st_size, + sha256=hashlib.sha256((review / filename).read_bytes()).hexdigest())) + print('REVIEW_RENDER ' + filename) + files = [file_record(blend), file_record(Path(__file__))] + [file_record(EXPORT / (name + '.fbx')) for name in sorted(ASSETS)] + manifest = dict(schemaVersion=1, status='CANDIDATE_USER_REVIEW_REQUIRED_NOT_ADOPTED', + creator='MagnetismHeroes project-authored native Blender geometry via tools/models/build_meadow.py', + license='Self-created project artwork; no third-party mesh or texture assets; no copied reference characters, guns, UI, or composition', + reference='cartoon-landscape.png viewed only for cyan sky, rounded nature, pastel vegetation, warm soft daylight', + tool='Blender ' + bpy.app.version_string, toolBuildHash=bpy.app.build_hash.decode(), + source='SourceAssets/Models/Meadow/MeadowCandidates.blend', + coordinateConvention='meters Z-up native; FBX -Z forward/Y-up; applied unit scale; floor pivots; later Unity globalScale=1 bakeAxisConversion=true and preserve imported root rotation', + exportSettings={k: sorted(v) if isinstance(v, set) else v for k, v in SETTINGS.items()}, + materialTransfer='native named material slots; constant Base Color and Roughness; UVMap; no Blender-only procedural surface or external texture dependency', + materials=[dict(name=m.name, authoredSRGB=list(m['authoredSRGB']), linearBaseColor=list(m.diffuse_color), + roughness=m.node_tree.nodes.get('Principled BSDF').inputs['Roughness'].default_value) for m in MATERIALS.values()], + assetNames=sorted(ASSETS), meshContracts=mesh_report, + previewScenes=[s.name for s in previews], reviewRenders=render_names, + reviewState=dict(blender='native source contracts and saved review renders; visual inspection recorded separately', + userApproval='PENDING', unityImport='NOT_RUN', gameplayDiscovery='NOT_RUN', mobilePerformance='NOT_RUN'), files=files) + (SOURCE / 'provenance.json').write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') + (review / 'render-manifest.json').write_text(json.dumps(render_records, indent=2) + '\n', encoding='utf-8') + verify_existing(review) + + +if __name__ == '__main__': + main()