Add Blender meadow model candidates for review
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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·화면 구성을 복제하지 않았다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
Reference in New Issue
Block a user