-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsmoke_test.gd
More file actions
465 lines (419 loc) · 21.7 KB
/
Copy pathsmoke_test.gd
File metadata and controls
465 lines (419 loc) · 21.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
## Headless smoke test: run with
## godot --headless -s res://tests/smoke_test.gd
## Verifies DB access, part scanning, .sk parsing, and character assembly.
extends SceneTree
var _failures := 0
var _checks := 0
func _init() -> void:
# The user's project settings may have combining enabled; tests control it explicitly.
ProjectSettings.set_setting("kickdot/build/combine_meshes", false)
# Keep all cache writes out of the project during tests.
ProjectSettings.set_setting(SKResourceCache.SETTING_CACHE_DIR, "user://test_kickdot_cache")
_test_database()
_test_part_library()
_test_availability()
_test_recipe_and_build()
_test_randomize()
if _checks < EXPECTED_MIN_CHECKS:
_failures += 1
printerr(" FAIL: only %d/%d checks ran - a script error aborted a test early" % [_checks, EXPECTED_MIN_CHECKS])
if _failures == 0:
print("SMOKE TEST: ALL PASSED (%d checks)" % _checks)
else:
print("SMOKE TEST: %d FAILURES" % _failures)
quit(1 if _failures > 0 else 0)
## Minimum number of _check calls when nothing aborts early. A GDScript runtime error
## exits the failing function silently, skipping its remaining checks - this guard
## turns such silent skips into a test failure. Update when adding checks.
const EXPECTED_MIN_CHECKS := 77
func _check(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" PASS: %s" % label)
else:
_failures += 1
printerr(" FAIL: %s" % label)
func _test_database() -> void:
print("[database]")
var db := SKDatabase.new()
_check(db.open(), "opens side_kick_data.db")
if not db.is_open():
return
_check(db.get_version() == "1.0.2", "version is 1.0.2 (got %s)" % db.get_version())
var species := db.get_species(false)
_check(species.size() == 6, "6 species excl. Unrestricted (got %d)" % species.size())
var parts := db.get_all_parts()
_check(parts.size() > 3000, "all parts metadata present (got %d)" % parts.size())
var presets := db.get_part_presets(1)
_check(presets.size() > 0, "human part presets exist (got %d)" % presets.size())
var props := db.get_color_properties()
_check(props.size() == 209, "209 color properties (got %d)" % props.size())
db.close()
func _test_part_library() -> void:
print("[part library]")
var library := SKPartLibrary.new()
library.scan()
_check(library.part_count() >= 150, "found starter part meshes (got %d)" % library.part_count())
_check(library.has_part("SK_HUMN_BASE_01_01HEAD_HU01"), "base head part found")
_check(SKEnums.extract_part_type("SK_HUMN_BASE_01_01HEAD_HU01") == SKEnums.PartType.HEAD, "part type extraction")
_check(SKEnums.outfit_name_from_part_name("SK_FANT_KNGT_17_10TORS_HU01") == "FANT_KNGT", "outfit extraction")
var torsos := library.get_parts_of_type(SKEnums.PartType.TORSO)
_check(torsos.size() >= 2, "multiple torso options (got %d)" % torsos.size())
func _test_availability() -> void:
print("[availability]")
var db := SKDatabase.new()
if not db.open():
_failures += 1
return
var library := SKPartLibrary.new()
library.scan()
# With only the starter pack installed the Unity tool offers Human + Unrestricted.
var species := db.get_available_species(library)
var names := species.map(func(s: Dictionary) -> String: return str(s.name))
# Content-dependent: the 1.2.2 base packages add Zombie (and Robot-linked) parts.
_check(names.has("Human") and names.has("Unrestricted"),
"available species include Human + Unrestricted (got %s)" % [names])
# Preset availability: every offered preset must have all its parts on disk.
var total := 0
for group: int in SKEnums.PART_GROUP_NAMES:
var presets := db.get_available_part_presets(library, group, 1 if group == SKEnums.PartGroup.HEAD else -1)
total += presets.size()
var all_valid := true
for preset in presets:
for row in db.get_part_preset_rows(int(preset.id)):
var part_name := str(row.get("part_name", ""))
if not part_name.is_empty() and part_name != "None" and not library.has_part(part_name):
all_valid = false
_check(all_valid, "%s presets all buildable (%d presets)" % [SKEnums.PART_GROUP_NAMES[group], presets.size()])
_check(total > 0, "some presets available with starter assets (got %d)" % total)
db.close()
func _test_randomize() -> void:
print("[randomize]")
var db := SKDatabase.new()
if not db.open():
_failures += 1
return
var library := SKPartLibrary.new()
library.scan()
# Simulate the dock's Randomize: random available preset per group -> recipe -> build.
var recipe := SKRecipe.new()
recipe.name = "RandomizedTest"
seed(12345)
for group: int in SKEnums.PART_GROUP_NAMES:
var presets := db.get_available_part_presets(library, group, 1 if group == SKEnums.PartGroup.HEAD else -1)
if presets.is_empty():
continue
var preset: Dictionary = presets[randi_range(0, presets.size() - 1)]
for row in db.get_part_preset_rows(int(preset.id)):
var part_name := str(row.get("part_name", ""))
if not part_name.is_empty() and part_name != "None":
recipe.set_part(part_name)
_check(recipe.parts.size() >= 10, "randomized recipe has parts (got %d)" % recipe.parts.size())
var builder := SKCharacterBuilder.new(library, null)
var character := builder.build(recipe)
_check(character != null, "randomized character builds")
if character != null:
var skeleton := character.get_child(0) as Skeleton3D
var mesh_count := skeleton.get_children().filter(func(c: Node) -> bool: return c is MeshInstance3D).size()
_check(mesh_count == recipe.parts.size(), "all randomized parts resolved (%d/%d)" % [mesh_count, recipe.parts.size()])
character.free()
db.close()
func _test_recipe_and_build() -> void:
print("[recipe + build]")
var recipe := SKRecipe.load_from_file("res://synty_assets/characters/Starter_01.sk")
_check(recipe != null, ".sk file parses")
if recipe == null:
return
_check(recipe.species_id == 1, "species is Human")
_check(recipe.parts.size() >= 20, "recipe has parts (got %d)" % recipe.parts.size())
_check(recipe.get_part(SKEnums.PartType.TORSO) == "SK_FANT_KNGT_17_10TORS_HU01", "torso part mapped")
_check(not recipe.color_rows.is_empty(), "color rows parsed (got %d)" % recipe.color_rows.size())
var library := SKPartLibrary.new()
library.scan()
var colors := SKColorEngine.new()
_check(colors.setup(), "color engine loads palette maps")
var builder := SKCharacterBuilder.new(library, colors)
var character := builder.build(recipe)
_check(character != null, "character builds")
if character == null:
return
var skeleton: Skeleton3D = character.get_child(0) as Skeleton3D
_check(skeleton != null, "skeleton created")
if skeleton != null:
_check(skeleton.get_bone_count() > 30, "skeleton has bones (got %d)" % skeleton.get_bone_count())
var mesh_count := 0
var skinned := 0
var with_blend_shapes := 0
for child in skeleton.get_children():
var mesh_instance := child as MeshInstance3D
if mesh_instance != null:
mesh_count += 1
if mesh_instance.skin != null:
skinned += 1
if mesh_instance.mesh.get_blend_shape_count() > 0:
with_blend_shapes += 1
_check(mesh_count >= 20, "one mesh per part (got %d)" % mesh_count)
_check(skinned == mesh_count, "all parts skinned (%d/%d)" % [skinned, mesh_count])
print(" INFO: %d meshes, %d with blend shapes, %d bones" % [mesh_count, with_blend_shapes, skeleton.get_bone_count()])
# Named skin binds are required for by-name binding to the shared skeleton.
var first_mesh := skeleton.get_child(0) as MeshInstance3D
if first_mesh != null and first_mesh.skin != null:
var named := 0
for i in first_mesh.skin.get_bind_count():
if first_mesh.skin.get_bind_name(i) != &"":
named += 1
_check(named == first_mesh.skin.get_bind_count(),
"skin binds are named (%d/%d)" % [named, first_mesh.skin.get_bind_count()])
# Round-trip save with full Unity-compatible color state.
var database := SKDatabase.new()
database.open()
var save_path := "user://smoke_roundtrip.sk"
_check(recipe.save_to_file(save_path, database, colors) == OK, "recipe saves")
var reloaded := SKRecipe.load_from_file(save_path)
_check(reloaded != null and reloaded.parts.size() == recipe.parts.size(), "recipe round-trips")
# The official tool requires the ColorSet and ColorRows sections to always exist.
var saved_text := FileAccess.get_file_as_string(save_path)
_check(saved_text.contains("ColorSet:") and saved_text.contains("SourceColorPath:"), "saved .sk has ColorSet block")
_check(saved_text.count("- ColorProperty:") == 209, "saved .sk has all 209 color rows (got %d)" % saved_text.count("- ColorProperty:"))
_check(saved_text.contains("BlendShapes:"), "saved .sk has BlendShapes block")
# A minimal recipe saved without a database must still emit both sections.
var bare := SKRecipe.new()
bare.save_to_file("user://smoke_bare.sk")
var bare_text := FileAccess.get_file_as_string("user://smoke_bare.sk")
_check(bare_text.contains("ColorSet:") and bare_text.contains("ColorRows: []"), "bare .sk still Unity-safe")
# Text round-trip (used by SidekickCharacter.recipe_text live-edit storage).
var text_clone := SKRecipe.from_text(recipe.to_text())
_check(text_clone.parts.size() == recipe.parts.size() and text_clone.species_id == recipe.species_id,
"recipe text round-trips (%d parts)" % text_clone.parts.size())
# Live rebuild: rebuilding into an existing node must not duplicate skeletons.
var host := SidekickCharacter.new()
var builder2 := SKCharacterBuilder.new(library, null)
_check(builder2.rebuild_into(host, recipe), "rebuild_into builds")
var small := SKRecipe.new()
small.set_part("SK_HUMN_BASE_01_01HEAD_HU01")
small.set_part("SK_HUMN_BASE_01_10TORS_HU01")
_check(builder2.rebuild_into(host, small), "rebuild_into replaces")
var skeletons := 0
var meshes := 0
for child in host.get_children():
if child is Skeleton3D:
skeletons += 1
meshes = child.get_children().filter(func(c: Node) -> bool: return c is MeshInstance3D).size()
_check(skeletons == 1, "single skeleton after rebuild (got %d)" % skeletons)
_check(meshes == 2, "rebuilt with new part set (got %d meshes)" % meshes)
host.free()
# Self-building node: recipe stored on the node is the only saved state; the scene
# file must stay small (generated children unowned => not serialized).
var self_built := SidekickCharacter.new()
self_built.recipe_text = recipe.to_text()
self_built.color_cells = [{ "u": 0, "v": 5, "color": Color.RED }]
_check(self_built.rebuild(library), "SidekickCharacter self-rebuilds from recipe_text")
var built_skeleton: Skeleton3D = null
for child in self_built.get_children():
if child is Skeleton3D:
built_skeleton = child
_check(built_skeleton != null and built_skeleton.get_child_count() >= 20, "self-build produced meshes")
# User content under the skeleton must survive a rebuild; the skeleton must be reused.
var attachment := BoneAttachment3D.new()
attachment.name = "UserAttachment"
built_skeleton.add_child(attachment)
_check(self_built.rebuild(library), "second rebuild")
var skeleton_after: Skeleton3D = null
for child in self_built.get_children():
if child is Skeleton3D:
skeleton_after = child
_check(skeleton_after == built_skeleton, "skeleton instance persists across rebuilds")
_check(skeleton_after.get_node_or_null("UserAttachment") != null, "user BoneAttachment3D survives rebuild")
# Live color repaint: material-only update, no mesh rebuild.
self_built.color_cells = [{ "u": 0, "v": 5, "color": Color.GREEN }]
self_built.repaint_colors()
var repaint_mesh: MeshInstance3D = null
for child in built_skeleton.get_children():
if child is MeshInstance3D:
repaint_mesh = child
break
var repaint_material: ShaderMaterial = null
if repaint_mesh != null:
repaint_material = repaint_mesh.material_override as ShaderMaterial
_check(repaint_material != null, "repaint applies shader material")
if repaint_material != null:
var color_map := repaint_material.get_shader_parameter("color_map") as ImageTexture
var painted := color_map.get_image().get_pixel(0, color_map.get_image().get_height() - 2 - 5 * 2)
_check(painted.is_equal_approx(Color.GREEN), "repaint paints the palette cell (got %s)" % painted)
var probe := SKColorEngine.new()
if probe.setup():
probe.apply_cells([{ "u": 0, "v": 5, "color": Color.GREEN }])
_check(probe.get_cell_color(SKEnums.ColorType.MAIN_COLOR, 0, 5).is_equal_approx(Color.GREEN),
"get_cell_color reads back the painted cell")
# Saved scene: skeleton persists (owned), part meshes regenerate (unowned) => small file.
built_skeleton.owner = self_built
var packer := PackedScene.new()
packer.pack(self_built)
var scene_path := "user://smoke_character_scene.tscn"
ResourceSaver.save(packer, scene_path)
var scene_size := FileAccess.get_file_as_bytes(scene_path).size()
_check(scene_size < 256 * 1024, "saved character scene is small (%d bytes)" % scene_size)
var scene_text := FileAccess.get_file_as_string(scene_path)
_check(scene_text.contains("Skeleton3D"), "skeleton is saved in the scene")
_check(not scene_text.contains("ArrayMesh"), "no mesh data embedded in the scene")
var reloaded_scene := (load(scene_path) as PackedScene).instantiate() as SidekickCharacter
_check(reloaded_scene.needs_build(), "reloaded scene knows it needs a rebuild")
reloaded_scene.free()
# Owned-mesh mode: resources persist to the binary cache; the scene references them
# externally, so meshes are visible in the tree yet the file stays small.
var cached := 0
var first_mesh_path := ""
for child in built_skeleton.get_children():
var mesh_instance := child as MeshInstance3D
if mesh_instance != null and mesh_instance.has_meta(SKCharacterBuilder.GENERATED_META):
SKResourceCache.persist_mesh_instance(mesh_instance)
mesh_instance.owner = self_built
cached += 1
if first_mesh_path.is_empty():
first_mesh_path = mesh_instance.mesh.resource_path
_check(cached > 0 and not first_mesh_path.is_empty() and not first_mesh_path.contains("::"),
"generated mesh has cache-backed path (%s)" % first_mesh_path)
var owned_packer := PackedScene.new()
owned_packer.pack(self_built)
var owned_path := "user://smoke_character_owned.tscn"
ResourceSaver.save(owned_packer, owned_path)
var owned_text := FileAccess.get_file_as_string(owned_path)
var owned_size := owned_text.length()
_check(owned_text.contains("test_kickdot_cache"), "scene references cached .res files")
_check(not owned_text.contains("ArrayMesh_"), "no embedded mesh data with owned meshes")
_check(owned_size < 512 * 1024, "owned-mesh scene still small (%d bytes)" % owned_size)
var owned_reload := (load(owned_path) as PackedScene).instantiate() as SidekickCharacter
_check(not owned_reload.needs_build(), "owned-mesh scene loads complete (no rebuild needed)")
_check(owned_reload.find_children("*", "MeshInstance3D", true, false).size() >= 20,
"owned-mesh scene restores meshes from cache")
owned_reload.free()
self_built.free()
# GLB export: meshes AND materials/textures must survive (ShaderMaterial doesn't
# translate to glTF, so export uses a baked StandardMaterial3D).
var export_colors := SKColorEngine.new()
var export_builder := SKCharacterBuilder.new(library, null)
if export_colors.setup():
export_builder.material_override = export_colors.bake_standard_material()
var export_character := export_builder.build(recipe)
var document := GLTFDocument.new()
var state := GLTFState.new()
var export_error := document.append_from_scene(export_character, state)
if export_error == OK:
export_error = document.write_to_filesystem(state, "user://smoke_export.glb")
_check(export_error == OK, "GLB export writes")
export_character.free()
var read_state := GLTFState.new()
var read_error := GLTFDocument.new().append_from_file(
ProjectSettings.globalize_path("user://smoke_export.glb"), read_state)
_check(read_error == OK, "exported GLB reads back")
_check(read_state.meshes.size() >= 30, "exported GLB has meshes (got %d)" % read_state.meshes.size())
_check(read_state.textures.size() > 0, "exported GLB has textures (got %d)" % read_state.textures.size())
_test_combined_mesh(library, recipe)
database.close()
character.free()
func _test_combined_mesh(library: SKPartLibrary, recipe: SKRecipe) -> void:
print("[combined mesh]")
# Reference build with separate meshes.
var separate_builder := SKCharacterBuilder.new(library, null)
separate_builder.combine_meshes = false
var separate := separate_builder.build(recipe)
var separate_vertices := 0
var separate_meshes := 0
var shape_union := {}
var bone_union := {}
for mesh_instance: MeshInstance3D in separate.find_children("*", "MeshInstance3D", true, false):
separate_meshes += 1
for surface in mesh_instance.mesh.get_surface_count():
var arrays := (mesh_instance.mesh as ArrayMesh).surface_get_arrays(surface)
separate_vertices += (arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
for b in mesh_instance.mesh.get_blend_shape_count():
shape_union[SKMeshCombiner.blend_shape_suffix(String(mesh_instance.mesh.get_blend_shape_name(b)))] = true
for i in mesh_instance.skin.get_bind_count():
bone_union[String(mesh_instance.skin.get_bind_name(i))] = true
separate.free()
var combined_builder := SKCharacterBuilder.new(library, null)
combined_builder.combine_meshes = true
var combined := combined_builder.build(recipe)
_check(combined != null, "combined build succeeds")
if combined == null:
return
var meshes := combined.find_children("*", "MeshInstance3D", true, false)
_check(meshes.size() == 1, "single combined mesh (got %d, separate had %d)" % [meshes.size(), separate_meshes])
var combined_instance: MeshInstance3D = meshes[0]
var combined_mesh := combined_instance.mesh as ArrayMesh
var combined_vertices := (combined_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] as PackedVector3Array).size()
_check(combined_vertices == separate_vertices, "vertex count preserved (%d vs %d)" % [combined_vertices, separate_vertices])
_check(combined_mesh.get_blend_shape_count() == shape_union.size(),
"blend shapes merged by name (%d vs union %d)" % [combined_mesh.get_blend_shape_count(), shape_union.size()])
_check(combined_instance.skin.get_bind_count() == bone_union.size(),
"unified skin covers all bones (%d vs %d)" % [combined_instance.skin.get_bind_count(), bone_union.size()])
_check(combined_mesh.surface_get_format(0) & Mesh.ARRAY_FORMAT_BONES != 0, "combined mesh is skinned")
# GLB export of the combined character.
var document := GLTFDocument.new()
var state := GLTFState.new()
var error := document.append_from_scene(combined, state)
if error == OK:
error = document.write_to_filesystem(state, "user://smoke_export_combined.glb")
_check(error == OK and state.meshes.size() == 1, "combined GLB exports 1 mesh (got %d)" % state.meshes.size())
combined.free()
_test_rig_movement_and_baking(library, recipe)
func _test_rig_movement_and_baking(library: SKPartLibrary, recipe: SKRecipe) -> void:
print("[rig movement + baking]")
var db := SKDatabase.new()
db.open()
SKRigMovement.bake(db)
_check(FileAccess.file_exists("user://test_kickdot_cache/rig_movements.json"), "rig movements bake to cache")
# uses_wrap flag reachable for wrap automation.
var torso := db.get_part_by_name("SK_FANT_KNGT_17_10TORS_HU01")
_check(torso.has("uses_wrap"), "torso part exposes uses_wrap (got %s)" % torso.get("uses_wrap"))
db.close()
# Heavy body: socket bones must move away from their rest pose.
var heavy := SKRecipe.from_text(recipe.to_text())
heavy.body_size_value = 100.0
var builder := SKCharacterBuilder.new(library, null)
builder.combine_meshes = false
var character := builder.build(heavy)
var skeleton: Skeleton3D = character.get_child(0)
var bone := skeleton.find_bone("hipAttach_l")
_check(bone >= 0, "hipAttach_l bone exists")
if bone >= 0:
var rest := skeleton.get_bone_rest(bone).origin
var pose := skeleton.get_bone_pose_position(bone)
_check(pose.distance_to(rest) > 0.05, "heavy body moves hip socket (moved %.4f)" % pose.distance_to(rest))
var back := skeleton.find_bone("backAttach")
if back >= 0:
_check(skeleton.get_bone_pose_position(back).distance_to(skeleton.get_bone_rest(back).origin) > 0.001,
"heavy body moves back socket")
character.free()
# Neutral body: sockets stay at rest.
var neutral_character := builder.build(recipe)
var neutral_skeleton: Skeleton3D = neutral_character.get_child(0)
var neutral_bone := neutral_skeleton.find_bone("hipAttach_l")
# Starter_01 defaults: type 50/muscle 50 give small feminine/bulk offsets; just
# verify it moved less than the heavy build.
if neutral_bone >= 0:
var neutral_delta := neutral_skeleton.get_bone_pose_position(neutral_bone).distance_to(
neutral_skeleton.get_bone_rest(neutral_bone).origin)
_check(neutral_delta < 0.05, "neutral body barely moves sockets (%.4f)" % neutral_delta)
neutral_character.free()
# Baking: dropping body shapes at heavy=100 must bake the shape into the vertices.
var bake_builder := SKCharacterBuilder.new(library, null)
bake_builder.combine_meshes = true
bake_builder.combine_body_blend_shapes = false
bake_builder.combine_facial_blend_shapes = false
var baked_heavy := bake_builder.build(heavy)
var baked_neutral_recipe := SKRecipe.from_text(recipe.to_text())
baked_neutral_recipe.body_size_value = 0.0
baked_neutral_recipe.body_type_value = 0.0
var baked_neutral := bake_builder.build(baked_neutral_recipe)
var heavy_mesh := (baked_heavy.find_children("*", "MeshInstance3D", true, false)[0] as MeshInstance3D).mesh as ArrayMesh
var neutral_mesh := (baked_neutral.find_children("*", "MeshInstance3D", true, false)[0] as MeshInstance3D).mesh as ArrayMesh
_check(heavy_mesh.get_blend_shape_count() == 0, "dropped all blend shapes (got %d)" % heavy_mesh.get_blend_shape_count())
var heavy_vertices: PackedVector3Array = heavy_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
var neutral_vertices: PackedVector3Array = neutral_mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX]
var max_delta := 0.0
for i in mini(heavy_vertices.size(), neutral_vertices.size()):
max_delta = maxf(max_delta, heavy_vertices[i].distance_to(neutral_vertices[i]))
_check(max_delta > 0.01, "heavy weights baked into vertices (max delta %.4f)" % max_delta)
baked_heavy.free()
baked_neutral.free()