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
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
# Magnetic knight candidate
This is a model review candidate. It is not connected to a prefab, scene, stage,
collider, input target or game state. User model/material approval precedes game
adoption. No Unity import or Android appearance/selection pass is claimed here.
`MagneticKnight.blend` contains separately named editable collections for the
one-meter spherical body, knight equipment, existing-face preview, thin ribbon
preview, and render studio. The face atlas is packed unchanged for portability.
The review face and ribbon are excluded from the FBX exports; retain the game's
six-expression SpriteRenderer and existing animated light ribbon during later
integration. There is no new face, head, arm, leg, rig or animation.
`HeroBody.fbx` has a body-centered pivot and radius 0.5 meters. `KnightEquipment.fbx`
has the same attachment origin and contains named meshes for the open helmet,
short rounded toy sword, and layered round magnet-emblem shield. Object scales
are applied. Authoring is Z up in meters; exports use -Z forward / Y up without
animation. Preserve the imported axis transform when creating the later wrapper.
The existing SphereCollider radius 0.5, root scale 1, and ball IDs remain owned by
the existing game. Equipment must stay visual-only, without colliders or input
targets. The equipment is a GroupBadge candidate for one representative per group.
The opaque export materials use uniform portable base colors, roughness and
metallic values, with no procedural texture or Blender-only shader dependency.
Their exact values are in `provenance.json`. The body is smooth with welded poles
and no geometric seam. Helmet details follow its shell, and sword/shield stay
beside the open eye-and-mouth area. Verify numbers, selection highlights, crowded
groups and camera-facing behavior in Unity after approval; Blender shots alone
do not prove those gameplay checks.
Regeneration (from this worktree root, with the Blender execution owner):
```powershell
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python tools/models/build_hero.py -- --review-dir 'C:/git/MagnetismHeroes/artifacts/verification/VisualUpgrade/ModelReview/Heroes'
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python tools/models/build_hero.py -- --verify-only
```
The tool only writes the Heroes source/export folders and the specified review
folder. It does not run the initial scenery builder. Provenance includes the
actual Blender version, source/export bytes and SHA256, existing sprite records,
material values, source licensing, and review limits. `verification.json` records
the actual source reopen, body radius/scale/geometry gates and FBX reimport checks.
Unity-generated `.meta` files are deliberately absent from this candidate; the
later Unity import owner must create them after approval.
+212
View File
@@ -0,0 +1,212 @@
{
"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": "5.2.2 LTS"
},
"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": "Hero_CyanSatin",
"baseColorSrgb": [
0.17,
0.72,
0.83
],
"roughness": 0.48,
"metallic": 0
},
{
"name": "Helmet_SilverTint",
"baseColorSrgb": [
0.72,
0.85,
0.86
],
"roughness": 0.39,
"metallic": 0.18
},
{
"name": "Ceramic_WarmCream",
"baseColorSrgb": [
0.97,
0.92,
0.78
],
"roughness": 0.43,
"metallic": 0
},
{
"name": "Trim_HoneyGold",
"baseColorSrgb": [
0.94,
0.71,
0.34
],
"roughness": 0.42,
"metallic": 0.12
},
{
"name": "Enamel_DeepTeal",
"baseColorSrgb": [
0.055,
0.4,
0.44
],
"roughness": 0.42,
"metallic": 0
},
{
"name": "Blade_SatinSilver",
"baseColorSrgb": [
0.85,
0.92,
0.91
],
"roughness": 0.4,
"metallic": 0.2
}
],
"existingFace": {
"source": {
"path": "SourceAssets/Art/Faces/face-expressions-v2.png",
"bytes": 1609191,
"sha256": "0e3c115619c4ae68cf8859ccbc6d4bfb3a26a1a247b3ddb41327c46829540c98"
},
"runtime": {
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Textures/FaceExpressions.png",
"bytes": 1609191,
"sha256": "0e3c115619c4ae68cf8859ccbc6d4bfb3a26a1a247b3ddb41327c46829540c98"
},
"spriteRecords": [
{
"name": "Neutral",
"rect": [
0.0,
512.0,
682.0,
512.0
],
"pivot": [
0.5,
0.5
],
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face0.asset",
"sha256": "a351d09aef580f8bf3d73401008aa93c7fffb250ea010595824dff15766dfcf6"
},
{
"name": "Interested",
"rect": [
682.0,
512.0,
682.0,
512.0
],
"pivot": [
0.5,
0.5
],
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face1.asset",
"sha256": "a555b81eb573044b53edc7c57bbc2937ba1e7d789527bae02f97686cce6ff816"
},
{
"name": "Focused",
"rect": [
1365.0,
512.0,
682.0,
512.0
],
"pivot": [
0.5,
0.5
],
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face2.asset",
"sha256": "6c8cfcc79bf7ab2781b39303ebd6e723fd0f96aec9f30378c7197bb73720d020"
},
{
"name": "Laughing",
"rect": [
0.0,
0.0,
682.0,
512.0
],
"pivot": [
0.5,
0.68
],
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face3.asset",
"sha256": "9130d438849dc678504cc939f51c9ea91c5b1dff97b7f7f9248b2f617af4da26"
},
{
"name": "Encouraging",
"rect": [
682.0,
0.0,
682.0,
512.0
],
"pivot": [
0.5,
0.68
],
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face4.asset",
"sha256": "8325c280f8fd83b1f55102593dc772153a3953a70e818467894d7ac98c9f87c0"
},
{
"name": "Celebrating",
"rect": [
1365.0,
0.0,
682.0,
512.0
],
"pivot": [
0.5,
0.68
],
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Sprites/Face5.asset",
"sha256": "a63967c62e7f1ddebe5cfa073b536992b1e02f2ff3aeacd515dc9cb230dca321"
}
],
"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": [
{
"path": "SourceAssets/Models/Heroes/MagneticKnight.blend",
"bytes": 1833499,
"sha256": "7ce1272dfe861aee986609cdb899f0edf8b8f2f0f17269c0eda472c8aa90ee3a"
},
{
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/HeroBody.fbx",
"bytes": 60076,
"sha256": "56f3301176c612ebeab3fdaaba5c019674beeb0f727f97ed27dd67e32ff50a40"
},
{
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/KnightEquipment.fbx",
"bytes": 222764,
"sha256": "a8059a020e79e080635e7326bb91576010cad10c60ee931d95536d47fe76e61c"
}
],
"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": "C:\\git\\MagnetismHeroes\\artifacts\\verification\\VisualUpgrade\\ModelReview\\Heroes"
}
@@ -0,0 +1,364 @@
{
"tool": "5.2.2 LTS",
"sourceReopened": true,
"sourceAssertions": {
"meshCount": 23,
"triangles": 15652,
"bodyRadiusMeters": 0.5,
"sourceBounds": {
"HeroBody": [
[
-0.5,
0.5
],
[
-0.5,
0.5
],
[
-0.5,
0.5
]
],
"Helmet_OpenShell": [
[
-0.5319715738296509,
0.5319715738296509
],
[
-0.445354163646698,
0.5319715738296509
],
[
-0.07078947126865387,
0.5379999876022339
]
],
"Helmet_RolledCreamRim": [
[
-0.5453746318817139,
0.5453530550003052
],
[
-0.4633394181728363,
0.5453746318817139
],
[
-0.08790849149227142,
0.3558972179889679
]
],
"Helmet_CrownGoldRail": [
[
-0.02800000086426735,
0.02800000086426735
],
[
-0.45152127742767334,
0.45152127742767334
],
[
0.32673779129981995,
0.5860000252723694
]
],
"Helmet_CrownTealInlay": [
[
-0.014000000432133675,
0.014000000432133675
],
[
-0.44023397564888,
0.44023397564888
],
[
0.3551320731639862,
0.5903148055076599
]
],
"Helmet_Hinge_-1": [
[
-0.5070000290870667,
-0.4230000078678131
],
[
-0.25200000405311584,
-0.2280000001192093
],
[
-0.007000000216066837,
0.07699999958276749
]
],
"Helmet_HingeInset_-1": [
[
-0.4869999885559082,
-0.4429999887943268
],
[
-0.26350000500679016,
-0.25049999356269836
],
[
0.013000000268220901,
0.05700000002980232
]
],
"Helmet_Hinge_1": [
[
0.4230000078678131,
0.5070000290870667
],
[
-0.25200000405311584,
-0.2280000001192093
],
[
-0.007000000216066837,
0.07699999958276749
]
],
"Helmet_HingeInset_1": [
[
0.4429999887943268,
0.4869999885559082
],
[
-0.26350000500679016,
-0.25049999356269836
],
[
0.013000000268220901,
0.05700000002980232
]
],
"Sword_SoftBlade": [
[
-0.7200000286102295,
-0.6150963306427002
],
[
-0.13249999284744263,
-0.07750000059604645
],
[
-0.014999999664723873,
0.47232866287231445
]
],
"Sword_CenterInlay": [
[
-0.6919999718666077,
-0.649011492729187
],
[
-0.14300000667572021,
-0.13500000536441803
],
[
0.03500000014901161,
0.4301910102367401
]
],
"Sword_SoftCrossguard": [
[
-0.7879676222801208,
-0.537032425403595
],
[
-0.14749999344348907,
-0.0625
],
[
-0.05999999865889549,
0.00800000037997961
]
],
"Sword_TealGrip": [
[
-0.7042268514633179,
-0.6257731914520264
],
[
-0.14249999821186066,
-0.06750000268220901
],
[
-0.19499999284744263,
-0.03999999910593033
]
],
"Sword_CreamPommel": [
[
-0.7149999737739563,
-0.6150000095367432
],
[
-0.14249999821186066,
-0.06750000268220901
],
[
-0.2549999952316284,
-0.1550000011920929
]
],
"Sword_GripBand-0.08": [
[
-0.7039999961853027,
-0.6309999823570251
],
[
-0.14550000429153442,
-0.06650000065565109
],
[
-0.0860000029206276,
-0.07400000095367432
]
],
"Sword_GripBand-0.13": [
[
-0.7039999961853027,
-0.6309999823570251
],
[
-0.14550000429153442,
-0.06650000065565109
],
[
-0.13600000739097595,
-0.12399999797344208
]
],
"Sword_GripBand-0.18": [
[
-0.7039999961853027,
-0.6309999823570251
],
[
-0.14550000429153442,
-0.06650000065565109
],
[
-0.1860000044107437,
-0.17399999499320984
]
],
"Shield_CreamRolledEdge": [
[
0.33899998664855957,
0.7950000166893005
],
[
-0.20499999821186066,
-0.13500000536441803
],
[
-0.37167999148368835,
0.11168000102043152
]
],
"Shield_GoldInsetRing": [
[
0.36500000953674316,
0.7689999938011169
],
[
-0.22750000655651093,
-0.19850000739097595
],
[
-0.34411999583244324,
0.0841199979186058
]
],
"Shield_TealFace": [
[
0.3869999945163727,
0.746999979019165
],
[
-0.24449999630451202,
-0.2214999943971634
],
[
-0.3208000063896179,
0.06080000102519989
]
],
"Shield_MagnetEmblem": [
[
0.4659999907016754,
0.6680000424385071
],
[
-0.28299999237060547,
-0.2409999966621399
],
[
-0.20499999821186066,
-0.057999998331069946
]
],
"Shield_MagnetPole_0.487": [
[
0.46299999952316284,
0.5109999775886536
],
[
-0.28049999475479126,
-0.24549999833106995
],
[
-0.07500000298023224,
-0.03799999877810478
]
],
"Shield_MagnetPole_0.647": [
[
0.6230000257492065,
0.6710000038146973
],
[
-0.28049999475479126,
-0.24549999833106995
],
[
-0.07500000298023224,
-0.03799999877810478
]
]
}
},
"fbxReimportChecks": [
{
"file": "HeroBody.fbx",
"meshCount": 1,
"nonEmpty": true,
"scaleOne": true
},
{
"file": "KnightEquipment.fbx",
"meshCount": 22,
"nonEmpty": true,
"scaleOne": true
}
],
"files": [
{
"path": "SourceAssets/Models/Heroes/MagneticKnight.blend",
"bytes": 1833499,
"sha256": "7ce1272dfe861aee986609cdb899f0edf8b8f2f0f17269c0eda472c8aa90ee3a"
},
{
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/HeroBody.fbx",
"bytes": 60076,
"sha256": "56f3301176c612ebeab3fdaaba5c019674beeb0f727f97ed27dd67e32ff50a40"
},
{
"path": "Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/KnightEquipment.fbx",
"bytes": 222764,
"sha256": "a8059a020e79e080635e7326bb91576010cad10c60ee931d95536d47fe76e61c"
}
]
}
+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')