"""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()