Add Blender meadow model candidates for review

This commit is contained in:
NINI
2026-10-08 02:07:12 +09:00
25 changed files with 1790 additions and 0 deletions
+756
View File
@@ -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()