Add native meadow scenery candidates and reproducible Blender review exports
This commit is contained in:
@@ -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