diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/HeroBody.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/HeroBody.fbx new file mode 100644 index 0000000..0c4f6d0 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/HeroBody.fbx differ diff --git a/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/KnightEquipment.fbx b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/KnightEquipment.fbx new file mode 100644 index 0000000..6a7f625 Binary files /dev/null and b/Assets/MagnetismHeroes/UnityRuntime/Presentation/Models/Heroes/KnightEquipment.fbx differ diff --git a/SourceAssets/Models/Heroes/MagneticKnight.blend b/SourceAssets/Models/Heroes/MagneticKnight.blend new file mode 100644 index 0000000..fc02ef8 Binary files /dev/null and b/SourceAssets/Models/Heroes/MagneticKnight.blend differ diff --git a/SourceAssets/Models/Heroes/README.md b/SourceAssets/Models/Heroes/README.md new file mode 100644 index 0000000..da45716 --- /dev/null +++ b/SourceAssets/Models/Heroes/README.md @@ -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에서 생성해야 한다. diff --git a/SourceAssets/Models/Heroes/provenance.json b/SourceAssets/Models/Heroes/provenance.json new file mode 100644 index 0000000..3073788 --- /dev/null +++ b/SourceAssets/Models/Heroes/provenance.json @@ -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" +} \ No newline at end of file diff --git a/SourceAssets/Models/Heroes/verification.json b/SourceAssets/Models/Heroes/verification.json new file mode 100644 index 0000000..88ff8a5 --- /dev/null +++ b/SourceAssets/Models/Heroes/verification.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/tools/models/build_hero.py b/tools/models/build_hero.py new file mode 100644 index 0000000..1cac415 --- /dev/null +++ b/tools/models/build_hero.py @@ -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')