Create review-only magnetic knight body and equipment models
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user