"""Author an unadopted magnetic knight candidate; Blender owns all mesh generation. Blender --background --python tools/models/build_hero.py -- --review-dir PATH Blender --background --python tools/models/build_hero.py -- --verify-only Existing game assets, sprites, prefabs and import settings are read only. """ import argparse import hashlib import json import math from pathlib import Path import re import sys import bpy from mathutils import Vector ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / 'SourceAssets/Models/Heroes' EXPORT = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes' ATLAS = ROOT / 'SourceAssets/Art/Faces/face-expressions-v2.png' RUNTIME_ATLAS = ROOT / 'Assets/MagnetismHeroes/UnityRuntime/Presentation/Textures/FaceExpressions.png' BLEND = SOURCE / 'MagneticKnight.blend' parser = argparse.ArgumentParser() parser.add_argument('--review-dir', type=Path) parser.add_argument('--verify-only', action='store_true') args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []) def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest() def srgb(value): return value / 12.92 if value <= .04045 else ((value + .055) / 1.055) ** 2.4 def material(name, color, roughness=.45, metallic=0): mat = bpy.data.materials.new(name) mat.diffuse_color = (*[srgb(c) for c in color], 1) mat.use_nodes = True node = mat.node_tree.nodes.get('Principled BSDF') node.inputs['Base Color'].default_value = mat.diffuse_color node.inputs['Roughness'].default_value = roughness node.inputs['Metallic'].default_value = metallic mat['base_color_srgb'] = list(color) mat['portable_roughness'] = roughness mat['portable_metallic'] = metallic return mat def collection(name): coll = bpy.data.collections.new(name) bpy.context.scene.collection.children.link(coll) return coll def mesh(name, vertices, faces, mat, coll, smooth=True): data = bpy.data.meshes.new(name + '_Mesh') data.from_pydata(vertices, [], faces) data.update() obj = bpy.data.objects.new(name, data) coll.objects.link(obj) data.materials.append(mat) for poly in data.polygons: poly.use_smooth = smooth return obj def activate(obj): bpy.ops.object.select_all(action='DESELECT') obj.select_set(True) bpy.context.view_layer.objects.active = obj def bevel(obj, width=.015, segments=3): activate(obj) mod = obj.modifiers.new('Manufactured_soft_edges', 'BEVEL') mod.width = width mod.segments = segments bpy.ops.object.modifier_apply(modifier=mod.name) normal = obj.modifiers.new('Consistent_corner_normals', 'WEIGHTED_NORMAL') normal.keep_sharp = True bpy.ops.object.modifier_apply(modifier=normal.name) def outline(name, points, thickness, mat, coll, y=0, edge=.01): count = len(points) verts = [(x, y - thickness / 2, z) for x, z in points] verts += [(x, y + thickness / 2, z) for x, z in points] faces = [tuple(range(count - 1, -1, -1)), tuple(range(count, count * 2))] faces += [(i, (i + 1) % count, (i + 1) % count + count, i + count) for i in range(count)] obj = mesh(name, verts, faces, mat, coll, False) bevel(obj, edge) return obj def tube(name, points, radius, mat, coll, sides=10): verts = [] for i, point in enumerate(points): tangent = Vector(points[min(i + 1, len(points) - 1)]) - Vector(points[max(i - 1, 0)]) tangent.normalize() normal = tangent.cross(Vector((0, 0, 1))) if normal.length < .01: normal = tangent.cross(Vector((0, 1, 0))) normal.normalize() second = tangent.cross(normal).normalized() for j in range(sides): verts.append(Vector(point) + radius * (normal * math.cos(j * math.tau / sides) + second * math.sin(j * math.tau / sides))) faces = [] for i in range(len(points) - 1): for j in range(sides): a = i * sides + j b = i * sides + (j + 1) % sides faces.append((a, b, b + sides, a + sides)) faces += [tuple(range(sides - 1, -1, -1)), tuple((len(points) - 1) * sides + j for j in range(sides))] return mesh(name, verts, faces, mat, coll) def disk(name, center, radius, depth, mat, coll, vertical_scale=1): count = 48 profile = [(radius - .009, -depth / 2), (radius, -depth / 2 + .008), (radius, depth / 2 - .008), (radius - .009, depth / 2)] verts = [(center[0], center[1] - depth / 2, center[2])] for r, y in profile: verts += [(center[0] + r * math.cos(i * math.tau / count), center[1] + y, center[2] + r * vertical_scale * math.sin(i * math.tau / count)) for i in range(count)] verts.append((center[0], center[1] + depth / 2, center[2])) faces = [(0, 1 + i, 1 + (i + 1) % count) for i in range(count)] for row in range(3): for i in range(count): a, b = 1 + row * count + i, 1 + row * count + (i + 1) % count faces.append((a, a + count, b + count, b)) faces += [(len(verts) - 1, 1 + 3 * count + (i + 1) % count, 1 + 3 * count + i) for i in range(count)] return mesh(name, verts, faces, mat, coll) def export(name, objects): bpy.ops.object.select_all(action='DESELECT') for obj in objects: obj.select_set(True) bpy.context.view_layer.objects.active = objects[0] bpy.ops.export_scene.fbx(filepath=str(EXPORT / (name + '.fbx')), use_selection=True, object_types={'MESH'}, axis_forward='-Z', axis_up='Y', global_scale=1, apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL', use_mesh_modifiers=True, bake_anim=False, add_leaf_bones=False, path_mode='AUTO') def aim(obj, point): obj.rotation_euler = (Vector(point) - obj.location).to_track_quat('-Z', 'Y').to_euler() def camera(location, target, size=1.95): cam = bpy.data.objects.get('ReviewCamera') if cam is None: data = bpy.data.cameras.new('ReviewCamera') cam = bpy.data.objects.new('ReviewCamera', data) review.objects.link(cam) # Keep every orthographic ray origin above the floor, including low front views. cam.location = Vector(target) + (Vector(location) - Vector(target)) * 4 aim(cam, target) cam.data.type = 'ORTHO' cam.data.ortho_scale = size bpy.context.scene.camera = cam def read_sprite(index): path = ROOT / f'Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face{index}.asset' text = path.read_text(encoding='utf-8') rect = re.search(r'm_Rect:\s*serializedVersion: \d+\s*x: ([\d.]+)\s*y: ([\d.]+)\s*width: ([\d.]+)\s*height: ([\d.]+)', text) pivot = re.search(r'm_Pivot: \{x: ([\d.]+), y: ([\d.]+)\}', text) return {'name': re.search(r'm_Name: (.+)', text).group(1), 'rect': list(map(float, rect.groups())), 'pivot': list(map(float, pivot.groups())), 'path': path.relative_to(ROOT).as_posix(), 'sha256': sha(path)} def expression(index): # Unity nPOTScale=1 imports the 1536-wide PNG at width 2048. # Existing Sprite rects are in imported pixels; use their normalized UVs. record = sprite_records[index] x, y, width, height = record['rect'] px, py = record['pivot'] for item, uv in zip(face.data.uv_layers.active.data, face_base_uv): item.uv = ((x + width * uv[0]) / 2048, (y + height * uv[1]) / 1024) # Maintain bottom-row .68 sprite pivot in the displayed plane. for vertex, original in zip(face.data.vertices, face_base_vertices): vertex.co = (original[0], original[1], original[2] + (.5 - py) * .48) def validate_source(): body = bpy.data.objects['HeroBody'] assert max(abs(v.co.length - .5) for v in body.data.vertices) < 1e-6, 'Body radius changed' assert tuple(body.location) == (0, 0, 0) all_mesh = [o for c in ('01_Body', '02_KnightEquipment') for o in bpy.data.collections[c].objects if o.type == 'MESH'] for obj in all_mesh: assert all(abs(s - 1) < 1e-6 for s in obj.scale), obj.name assert len(obj.data.materials) > 0 assert len(obj.modifiers) == 0 assert all(math.isfinite(v) for vertex in obj.data.vertices for v in vertex.co) assert all(len(p.vertices) >= 3 for p in obj.data.polygons) triangles = sum(sum(len(p.vertices) - 2 for p in o.data.polygons) for o in all_mesh) assert triangles <= 18000, triangles assert not any(o.type == 'ARMATURE' or o.animation_data for o in all_mesh) assert sha(ATLAS) == sha(RUNTIME_ATLAS), 'Atlas copies differ' print(f'ASSERTIONS_OK body_radius=.5 source_meshes={len(all_mesh)} triangles={triangles} root_scale=1 no_animation no_colliders') return {'meshCount': len(all_mesh), 'triangles': triangles, 'bodyRadiusMeters': .5, 'sourceBounds': {o.name: [[min(v.co[i] for v in o.data.vertices), max(v.co[i] for v in o.data.vertices)] for i in range(3)] for o in all_mesh}} def file_record(path): return {'path': path.relative_to(ROOT).as_posix(), 'bytes': path.stat().st_size, 'sha256': sha(path)} if args.verify_only: bpy.ops.wm.open_mainfile(filepath=str(BLEND)) results = validate_source() export_checks = [] for filename in ('HeroBody.fbx', 'KnightEquipment.fbx'): bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete(use_global=False) bpy.ops.import_scene.fbx(filepath=str(EXPORT / filename)) objects = [o for o in bpy.context.scene.objects if o.type == 'MESH'] assert objects, filename assert all(abs(s - 1) < 1e-5 for o in objects for s in o.scale) assert all(len(o.data.polygons) > 0 for o in objects) if filename == 'HeroBody.fbx': obj = objects[0] assert max(abs((obj.matrix_world @ v.co).length - .5) for v in obj.data.vertices) < 1e-5 export_checks.append({'file': filename, 'meshCount': len(objects), 'nonEmpty': True, 'scaleOne': True}) report = {'tool': bpy.app.version_string, 'sourceReopened': True, 'sourceAssertions': results, 'fbxReimportChecks': export_checks, 'files': [file_record(BLEND)] + [file_record(EXPORT / n) for n in ('HeroBody.fbx', 'KnightEquipment.fbx')]} (SOURCE / 'verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8') print('REOPEN_AND_FBX_REIMPORT_OK') sys.exit(0) SOURCE.mkdir(parents=True, exist_ok=True) EXPORT.mkdir(parents=True, exist_ok=True) bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete(use_global=False) for coll in list(bpy.data.collections): bpy.data.collections.remove(coll) body_coll = collection('01_Body') gear = collection('02_KnightEquipment') face_coll = collection('03_ExistingFace_ReviewOnly') ribbon_coll = collection('04_Ribbon_ReviewOnly') review = collection('90_ReviewStudio_NotExported') cyan = material('Hero_CyanSatin', (.17, .72, .83), .48) silver = material('Helmet_SilverTint', (.72, .85, .86), .39, .18) cream = material('Ceramic_WarmCream', (.97, .92, .78), .43) gold = material('Trim_HoneyGold', (.94, .71, .34), .42, .12) teal = material('Enamel_DeepTeal', (.055, .40, .44), .42) blade_mat = material('Blade_SatinSilver', (.85, .92, .91), .4, .2) # Seamless latitude mesh, welded poles and smooth normals; fixed diameter one meter. verts = [(0, 0, .5)] segments, rings = 64, 32 for row in range(1, rings): latitude = math.pi * row / rings for col in range(segments): longitude = math.tau * col / segments verts.append((.5 * math.sin(latitude) * math.cos(longitude), .5 * math.sin(latitude) * math.sin(longitude), .5 * math.cos(latitude))) verts.append((0, 0, -.5)) faces = [(0, 1 + col, 1 + (col + 1) % segments) for col in range(segments)] for row in range(rings - 2): for col in range(segments): a = 1 + row * segments + col b = 1 + row * segments + (col + 1) % segments faces.append((a, a + segments, b + segments, b)) faces += [(len(verts) - 1, 1 + (rings - 2) * segments + (col + 1) % segments, 1 + (rings - 2) * segments + col) for col in range(segments)] body = mesh('HeroBody', verts, faces, cyan, body_coll) # Open rounded helmet: raised front brow, protective sides/back, continuous rolled edge. verts, faces, rim = [(0, 0, .538)], [], [] steps = 14 for row in range(1, steps + 1): for col in range(64): phi = col * math.tau / 64 front = max(0, -math.sin(phi)) bottom_z = -.07 + .405 * front ** 4 theta = math.acos(bottom_z / .532) * row / steps point = (.532 * math.sin(theta) * math.cos(phi), .532 * math.sin(theta) * math.sin(phi), .538 * math.cos(theta)) verts.append(point) if row == steps: rim.append(point) for col in range(64): faces.append((0, 1 + col, 1 + (col + 1) % 64)) for row in range(steps - 1): for col in range(64): a, b = 1 + row * 64 + col, 1 + row * 64 + (col + 1) % 64 faces.append((a, a + 64, b + 64, b)) helmet = mesh('Helmet_OpenShell', verts, faces, silver, gear) activate(helmet) solid = helmet.modifiers.new('RealShellThickness', 'SOLIDIFY') solid.thickness = .018 solid.offset = -1 bpy.ops.object.modifier_apply(modifier=solid.name) tube('Helmet_RolledCreamRim', rim + [rim[0]], .018, cream, gear) # Low crown ridge tracks the shell rather than becoming a tall plume. ridge_points = [(0, -.43 + i * .86 / 28, math.sqrt(max(0, .54 ** 2 - (-.43 + i * .86 / 28) ** 2)) + .018) for i in range(29)] tube('Helmet_CrownGoldRail', ridge_points, .028, gold, gear, 12) tube('Helmet_CrownTealInlay', [(x, y, z + .019) for x, y, z in ridge_points], .014, teal, gear) for sign in (-1, 1): # Small recessed hinge / fastening details only, no arms or secondary heads. disk(f'Helmet_Hinge_{sign}', (sign * .465, -.24, .035), .042, .024, gold, gear) disk(f'Helmet_HingeInset_{sign}', (sign * .465, -.257, .035), .022, .013, teal, gear) # Rounded short toy blade with a shaped point, recessed full-length center inlay. sword = [] sword.append(outline('Sword_SoftBlade', [(-.66, -.015), (-.72, .39), (-.705, .445), (-.67, .478), (-.636, .445), (-.618, .39), (-.615, -.015)], .055, blade_mat, gear, -.105, .013)) sword.append(outline('Sword_CenterInlay', [(-.676, .035), (-.692, .383), (-.67, .432), (-.65, .383), (-.649, .035)], .008, cream, gear, -.139, .005)) sword.append(outline('Sword_SoftCrossguard', [(-.79, -.036), (-.785, .008), (-.54, .008), (-.535, -.036), (-.565, -.06), (-.76, -.06)], .085, gold, gear, -.105, .018)) sword.append(outline('Sword_TealGrip', [(-.705, -.195), (-.697, -.04), (-.633, -.04), (-.625, -.195)], .075, teal, gear, -.105, .015)) sword.append(disk('Sword_CreamPommel', (-.665, -.105, -.205), .05, .075, cream, gear)) for z in (-.08, -.13, -.18): sword.append(outline('Sword_GripBand' + str(z), [(-.704, z - .006), (-.704, z + .006), (-.631, z + .006), (-.631, z - .006)], .079, gold, gear, -.106, .004)) # Convex layered circular shield with an unmistakable horseshoe magnet emblem. disk('Shield_CreamRolledEdge', (.567, -.17, -.13), .228, .07, cream, gear, 1.06) disk('Shield_GoldInsetRing', (.567, -.213, -.13), .202, .029, gold, gear, 1.06) disk('Shield_TealFace', (.567, -.233, -.13), .18, .023, teal, gear, 1.06) magnet_points = [] for i in range(25): angle = math.pi + math.pi * i / 24 magnet_points.append((.567 + .080 * math.cos(angle), -.262, -.104 + .080 * math.sin(angle))) magnet_points = [(.487, -.262, -.058)] + magnet_points + [(.647, -.262, -.058)] tube('Shield_MagnetEmblem', magnet_points, .021, cream, gear, 12) for x in (.487, .647): outline('Shield_MagnetPole_' + str(x), [(x - .024, -.075), (x + .024, -.075), (x + .024, -.038), (x - .024, -.038)], .035, gold, gear, -.263, .006) # Atlas is packed unchanged; rendered face is review-only, game retains its sprites. sprite_records = [read_sprite(i) for i in range(6)] face_mat = material('ExistingAtlas_AlphaDecal_REVIEW_ONLY', (1, 1, 1), .65) nodes = face_mat.node_tree.nodes node = nodes.get('Principled BSDF') image = bpy.data.images.load(str(ATLAS), check_existing=True) image.pack() texture = nodes.new('ShaderNodeTexImage') texture.image = image texture.interpolation = 'Linear' face_mat.node_tree.links.new(texture.outputs['Color'], node.inputs['Base Color']) face_mat.node_tree.links.new(texture.outputs['Alpha'], node.inputs['Alpha']) face_mat.surface_render_method = 'DITHERED' face = mesh('ExistingExpression_ReviewOnly', [(-.39, -.505, -.26), (.39, -.505, -.26), (.39, -.505, .22), (-.39, -.505, .22)], [(0, 1, 2, 3)], face_mat, face_coll, False) uv = face.data.uv_layers.new(name='ExistingSpriteRect') face_base_uv = [(0, 0), (1, 0), (1, 1), (0, 1)] face_base_vertices = [tuple(v.co) for v in face.data.vertices] expression(0) # Review illustration of the existing light ribbon's thin taper, not a replacement cape. for side in (-1, 1): points = [(side * (.1 + i * .03), .41 + i * .025, .12 - i * .028 + .02 * math.sin(i * .5)) for i in range(13)] verts = [(x + offset, y, z) for i, (x, y, z) in enumerate(points) for offset in (-.038 * (1 - i / 18), .038 * (1 - i / 18))] faces = [(i * 2, i * 2 + 1, i * 2 + 3, i * 2 + 2) for i in range(12)] mesh('Ribbon_Reference_' + str(side), verts, faces, teal, ribbon_coll) scene = bpy.context.scene scene.unit_settings.system = 'METRIC' scene.unit_settings.scale_length = 1 scene.render.engine = 'CYCLES' prefs = bpy.context.preferences.addons['cycles'].preferences prefs.compute_device_type = 'OPTIX' prefs.get_devices() for device in prefs.devices: device.use = device.type == 'OPTIX' assert any(d.use for d in prefs.devices), 'Expected authorized OPTIX renderer unavailable' scene.cycles.device = 'GPU' scene.cycles.samples = 48 scene.cycles.use_denoising = True scene.render.resolution_x = 1000 scene.render.resolution_y = 1000 scene.render.resolution_percentage = 100 scene.render.image_settings.file_format = 'PNG' scene.view_settings.view_transform = 'AgX' scene.world.use_nodes = True scene.world.node_tree.nodes.get('Background').inputs[0].default_value = (.72, .84, .85, 1) scene.world.node_tree.nodes.get('Background').inputs[1].default_value = .35 floor_mat = material('Review_Backdrop_Mint', (.84, .91, .87), .8) mesh('ReviewGround', [(-200, -200, -.52), (200, -200, -.52), (200, 200, -.52), (-200, 200, -.52)], [(0, 1, 2, 3)], floor_mat, review, False) for name, location, energy, size, color in [ ('SoftKey', (-3, -4, 5), 400, 4, (1, .87, .72)), ('SkyFill', (4, -2, 2), 230, 3.5, (.67, .86, 1)), ('WarmRim', (1, 3, 4), 450, 3, (1, .93, .76))]: data = bpy.data.lights.new(name, 'AREA') data.energy, data.shape, data.size, data.color = energy, 'DISK', size, color obj = bpy.data.objects.new(name, data) review.objects.link(obj) obj.location = location aim(obj, (0, 0, 0)) camera((1.5, -3, 1.35), (0, 0, .01)) validate_source() export('HeroBody', list(body_coll.objects)) export('KnightEquipment', list(gear.objects)) bpy.ops.wm.save_as_mainfile(filepath=str(BLEND)) if args.review_dir: args.review_dir.mkdir(parents=True, exist_ok=True) shots = [('hero_three_quarter', (1.6, -3.2, 1.45)), ('knight_front', (0, -4, .3)), ('knight_side', (4, 0, .35)), ('knight_back', (0, 4, .3))] for name, position in shots: camera(position, (0, 0, .02), 1.95) scene.render.filepath = str(args.review_dir / (name + '.png')) bpy.ops.render.render(write_still=True) for obj in list(gear.objects) + list(face_coll.objects) + list(ribbon_coll.objects): obj.hide_render = True camera((1.3, -3.2, 1), (0, 0, 0), 1.5) scene.render.filepath = str(args.review_dir / 'body_standalone.png') bpy.ops.render.render(write_still=True) for obj in list(gear.objects) + list(face_coll.objects) + list(ribbon_coll.objects): obj.hide_render = False camera((0, -4, .15), (0, 0, .02), 1.95) scene.render.resolution_x = scene.render.resolution_y = 800 for i in range(6): expression(i) scene.render.filepath = str(args.review_dir / f'expression_{i}_{sprite_records[i]["name"].strip()}.png') bpy.ops.render.render(write_still=True) expression(0) camera((1.5, -3, 1.35), (0, 0, .01)) scene.render.resolution_x = scene.render.resolution_y = 1000 bpy.ops.wm.save_as_mainfile(filepath=str(BLEND)) manifest = { 'schemaVersion': 1, 'status': 'CANDIDATE_ONLY_USER_MODEL_REVIEW_REQUIRED', 'authoring': 'Project-original meshes made by tools/models/build_hero.py; no downloaded character geometry.', 'license': 'Original project-authored mesh/material work; existing generated face atlas retains SourceAssets/Art/Faces/provenance.json usage/service terms. No exclusive rights claim for generated decal.', 'tool': {'name': 'Blender', 'actualVersion': bpy.app.version_string}, 'units': 'meters; authoring Z up, body-centered pivot at origin; .5m body radius; scale applied, object scales 1', 'export': {'axisForward': '-Z', 'axisUp': 'Y', 'globalScale': 1, 'applyUnitScale': True, 'applyScaleOptions': 'FBX_SCALE_ALL', 'bakeAnim': False, 'selection': 'Body separately; knight equipment assembly of named meshes. Review face/ribbon/studio excluded.'}, 'attachments': {'Helmet': 'Upper sphere shell with open front, eye/mouth clearance', 'Sword': 'Body-left, short blunt toy blade; no arm, no collider', 'Shield': 'Body-right round layered shield; magnet emblem', 'pivot': 'Every exported mesh local origin is the same body center. Preserve imported axis transform in future wrapper.'}, 'portableMaterials': [{'name': m.name, 'baseColorSrgb': list(m['base_color_srgb']), 'roughness': m['portable_roughness'], 'metallic': m['portable_metallic']} for m in (cyan, silver, cream, gold, teal, blade_mat)], 'existingFace': {'source': file_record(ATLAS), 'runtime': file_record(RUNTIME_ATLAS), 'spriteRecords': sprite_records, 'reviewNormalization': 'Read existing rects and pivots. TextureImporter nPOTScale=1 imports original 1536x1024 PNG at 2048x1024. Review UV uses existing imported-grid x/2048,width/2048,y/1024,height/1024; pivot preserved. Existing files unchanged. Game face appearance/import still requires later review.'}, 'files': [file_record(BLEND)] + [file_record(EXPORT / name) for name in ('HeroBody.fbx', 'KnightEquipment.fbx')], 'reviewScope': 'Actual Blender renders and source/export geometry assertions. No Unity import, no Android, no gameplay/selection/UI-clearance pass claimed. Candidate files have no hand-authored .meta; future Unity import owner must generate them after approval.', 'renders': str(args.review_dir) if args.review_dir else None, } (SOURCE / 'provenance.json').write_text(json.dumps(manifest, indent=2), encoding='utf-8') print('CANDIDATE_AUTHORING_COMPLETE')