Create review-only magnetic knight body and equipment models

This commit is contained in:
NINI
2026-10-08 01:44:06 +09:00
parent 58410281e0
commit 5ad1a237c8
7 changed files with 1053 additions and 0 deletions
+433
View File
@@ -0,0 +1,433 @@
"""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')