Add Blender knight model candidates for review

This commit is contained in:
NINI
2026-10-08 02:01:09 +09:00
7 changed files with 1053 additions and 0 deletions
Binary file not shown.
+44
View File
@@ -0,0 +1,44 @@
# 자력 기사 모델 후보
사용자 모델·재질 검토를 위한 후보이며 아직 게임에 채택하지 않았다.
프리팹·씬·스테이지·콜라이더·입력 대상·게임 상태에 연결하지 않았고,
Unity 가져오기나 Android 표현·선택 검사를 통과했다고 주장하지 않는다.
게임 적용은 사용자 모델·재질 승인 이후에 진행한다.
`MagneticKnight.blend`에는 지름 1m 구 몸체, 기사 장비, 기존 얼굴 미리보기,
얇은 리본 미리보기, 렌더 스튜디오가 각각 이름 붙은 편집 가능한 컬렉션으로
분리되어 있다. 기존 얼굴 atlas는 바이트 변경 없이 원본에 패킹했다.
얼굴·리본 미리보기는 FBX에서 제외했으므로 후속 게임 적용에서는 기존
6표정 SpriteRenderer와 움직이는 빛 리본을 유지한다. 새 얼굴·머리·팔·다리·
리깅·애니메이션은 만들지 않았다.
`HeroBody.fbx`의 피벗은 몸체 중심이며 반지름은 0.5m다.
`KnightEquipment.fbx`는 같은 부착 기준 원점을 사용하고 열린 투구,
짧고 끝이 둥근 장난감 칼, 자석 문양의 둥근 다층 방패를 이름별 메시로 담는다.
배율을 적용하여 오브젝트 scale은 1이다. 원본은 m·Z-up이며 내보내기는
-Z forward·Y-up, 애니메이션 없음이다. 후속 wrapper를 만들 때 가져온
축 변환을 보존한다. 기존 SphereCollider 반지름 0.5, 루트 배율 1과 구 ID는
기존 게임이 소유한다. 장비는 콜라이더·입력 대상 없는 시각 장식으로 유지하고,
묶음별 대표 구 한 개의 GroupBadge에 부착할 후보로 취급한다.
내보낸 불투명 재질은 균일한 base color·roughness·metallic 값만 사용하며
절차적 텍스처나 Blender 전용 셰이더에 의존하지 않는다. 정확한 값은
`provenance.json`에 기록했다. 몸체는 극점을 연결한 매끄러운 메시이며
기하학적 이음새가 없다. 투구 세부 장식은 곡면을 따르고 칼·방패는 열린
눈·입 영역의 양옆에 배치했다. 숫자·선택 강조·밀집한 묶음·카메라 추종은
승인 후 Unity에서 확인해야 하며 Blender 렌더만으로 게임 검사를 대체하지 않는다.
재생성은 이 checkout 루트에서 Blender 실행 소유자와 순차 진행한다.
다른 PC에서는 설치된 Blender 실행 파일 위치를 사용한다.
```powershell
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python tools/models/build_hero.py -- --review-dir 'artifacts/verification/VisualUpgrade/ModelReview/Heroes'
& 'C:/Program Files/Blender Foundation/Blender 5.2/blender.exe' --background --python tools/models/build_hero.py -- --verify-only
```
도구는 Heroes 원본·내보내기 폴더와 지정한 검토 폴더만 작성하며 초기 배경
builder는 실행하지 않는다. 출처 기록에는 실제 Blender 버전, 원본·내보낸
파일의 바이트 수와 SHA256, 기존 sprite rect, 재질 값, 사용 조건과 검수 범위를
담았다. `verification.json`은 실제 원본 재열기, 몸체 반지름·배율·메시 검사와
FBX 재가져오기 결과를 담는다. 후보에는 수동 작성한 `.meta`가 없다.
후속 Unity 가져오기 담당자가 승인 이후 Unity에서 생성해야 한다.
+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')