refactor(M03): extract M2 placement grouper
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free contract, dependency and timing regression for ADT M2 grouping.
|
||||
|
||||
const GROUPER_SCRIPT := preload("res://src/render/m2/m2_placement_grouper.gd")
|
||||
const TRANSFORM_RESOLVER_SCRIPT := preload(
|
||||
"res://src/render/m2/m2_placement_transform_resolver.gd"
|
||||
)
|
||||
const GROUPER_PATH := "res://src/render/m2/m2_placement_grouper.gd"
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_empty_and_invalid_inputs(failures)
|
||||
_verify_historical_path_normalization(failures)
|
||||
_verify_group_order(failures)
|
||||
_verify_tile_local_defaults(failures)
|
||||
_verify_regular_transform(failures)
|
||||
_verify_waterfall_transform(failures)
|
||||
_verify_scale_clamp(failures)
|
||||
_verify_fresh_output(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
var elapsed_milliseconds := _verify_bounded_timing(failures)
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("M2_PLACEMENT_GROUPER: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print(
|
||||
"M2_PLACEMENT_GROUPER PASS cases=9 iterations=100 placements=256 elapsed_ms=%.3f"
|
||||
% elapsed_milliseconds
|
||||
)
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_empty_and_invalid_inputs(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
_expect_equal(
|
||||
grouper.group_placements(Vector3.ZERO, PackedStringArray(), []).size(),
|
||||
0,
|
||||
"empty",
|
||||
failures
|
||||
)
|
||||
var placements: Array = [
|
||||
"invalid",
|
||||
{"name_id": -1},
|
||||
{"name_id": 2},
|
||||
{"name_id": 0},
|
||||
]
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3.ZERO,
|
||||
PackedStringArray([""]),
|
||||
placements
|
||||
)
|
||||
_expect_equal(groups.size(), 0, "invalid placements", failures)
|
||||
|
||||
|
||||
func _verify_historical_path_normalization(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var names := PackedStringArray([
|
||||
"World\\Generic\\Tree.mdx",
|
||||
"World/Generic/Rock.mdl",
|
||||
"World/Generic/Upper.MDX",
|
||||
])
|
||||
var placements: Array = [{"name_id": 0}, {"name_id": 1}, {"name_id": 2}]
|
||||
var groups: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
_expect_true(groups.has("World/Generic/Tree.m2"), "backslash and mdx normalization", failures)
|
||||
_expect_true(groups.has("World/Generic/Rock.m2"), "mdl normalization", failures)
|
||||
_expect_true(groups.has("World/Generic/Upper.MDX"), "uppercase suffix preservation", failures)
|
||||
|
||||
|
||||
func _verify_group_order(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var placements: Array = [
|
||||
{"name_id": 0, "pos": Vector3(3.0, 0.0, 0.0)},
|
||||
{"name_id": 1, "pos": Vector3(8.0, 0.0, 0.0)},
|
||||
{"name_id": 0, "pos": Vector3(5.0, 0.0, 0.0)},
|
||||
]
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3.ZERO,
|
||||
PackedStringArray(["Tree.m2", "Rock.m2"]),
|
||||
placements
|
||||
)
|
||||
_expect_equal(groups.size(), 2, "group count", failures)
|
||||
var tree_transforms: Array = groups.get("Tree.m2", [])
|
||||
_expect_equal(tree_transforms.size(), 2, "tree transform count", failures)
|
||||
if tree_transforms.size() == 2:
|
||||
_expect_vector_near(tree_transforms[0].origin, Vector3(3.0, 0.0, 0.0), "first tree", failures)
|
||||
_expect_vector_near(tree_transforms[1].origin, Vector3(5.0, 0.0, 0.0), "second tree", failures)
|
||||
|
||||
|
||||
func _verify_tile_local_defaults(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3(10.0, 20.0, 30.0),
|
||||
PackedStringArray(["Tree.m2"]),
|
||||
[{"name_id": 0}]
|
||||
)
|
||||
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
|
||||
_expect_vector_near(transform.origin, Vector3(-10.0, -20.0, -30.0), "default local position", failures)
|
||||
_expect_basis_near(transform.basis, Basis.IDENTITY, "default basis and scale", failures)
|
||||
|
||||
|
||||
func _verify_regular_transform(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var rotation := Vector3(0.2, -0.4, 0.6)
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3(1.0, 2.0, 3.0),
|
||||
PackedStringArray(["Tree.m2"]),
|
||||
[{
|
||||
"name_id": 0,
|
||||
"pos": Vector3(5.0, 8.0, 13.0),
|
||||
"rot": rotation,
|
||||
"scale": 2.0,
|
||||
}]
|
||||
)
|
||||
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
|
||||
_expect_basis_near(transform.basis, Basis.from_euler(rotation).scaled(Vector3.ONE * 2.0), "regular basis", failures)
|
||||
_expect_vector_near(transform.origin, Vector3(4.0, 6.0, 10.0), "regular origin", failures)
|
||||
|
||||
|
||||
func _verify_waterfall_transform(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var resolver := TRANSFORM_RESOLVER_SCRIPT.new()
|
||||
var path := "World/X/ElwynnTallWaterfall01.m2"
|
||||
var rotation := Vector3(0.05, -0.2, 0.15)
|
||||
var scale_value := 1.5
|
||||
var position := Vector3(20.0, 30.0, 40.0)
|
||||
var tile_origin := Vector3(2.0, 3.0, 4.0)
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
tile_origin,
|
||||
PackedStringArray([path]),
|
||||
[{
|
||||
"name_id": 0,
|
||||
"pos": position,
|
||||
"rot": rotation,
|
||||
"scale": scale_value,
|
||||
}]
|
||||
)
|
||||
var transform: Transform3D = (groups[path] as Array)[0]
|
||||
var expected_basis: Basis = resolver.resolve_basis(rotation, path).scaled(
|
||||
Vector3.ONE * scale_value
|
||||
)
|
||||
var expected_origin: Vector3 = (
|
||||
position
|
||||
- tile_origin
|
||||
+ resolver.resolve_origin_offset(rotation, path, scale_value)
|
||||
)
|
||||
_expect_basis_near(transform.basis, expected_basis, "waterfall basis", failures)
|
||||
_expect_vector_near(transform.origin, expected_origin, "waterfall origin", failures)
|
||||
|
||||
|
||||
func _verify_scale_clamp(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var groups: Dictionary = grouper.group_placements(
|
||||
Vector3.ZERO,
|
||||
PackedStringArray(["Tree.m2"]),
|
||||
[{"name_id": 0, "scale": -5.0}]
|
||||
)
|
||||
var transform: Transform3D = (groups["Tree.m2"] as Array)[0]
|
||||
_expect_basis_near(
|
||||
transform.basis,
|
||||
Basis.IDENTITY.scaled(Vector3.ONE * 0.0001),
|
||||
"negative scale clamp",
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_fresh_output(failures: Array[String]) -> void:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var names := PackedStringArray(["Tree.m2"])
|
||||
var placements: Array = [{"name_id": 0}]
|
||||
var first: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
(first["Tree.m2"] as Array).clear()
|
||||
var second: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
_expect_equal((second["Tree.m2"] as Array).size(), 1, "fresh output", failures)
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var loader_source := _read_text(LOADER_PATH, failures)
|
||||
var grouper_source := _read_text(GROUPER_PATH, failures)
|
||||
var grouping_task_source := _source_between(
|
||||
loader_source,
|
||||
"func _group_tile_m2_task(",
|
||||
"func _drain_m2_group_results()",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
loader_source.count("_m2_placement_grouper.group_placements("),
|
||||
1,
|
||||
"one loader grouping adapter",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
not grouping_task_source.contains("for placement_variant in m2_placements"),
|
||||
"loader omits grouping loop",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_basis("),
|
||||
1,
|
||||
"one grouper basis adapter",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_origin_offset("),
|
||||
1,
|
||||
"one grouper offset adapter",
|
||||
failures
|
||||
)
|
||||
for forbidden_dependency in ["WorkerThreadPool", "Mutex", "RenderingServer", "ResourceLoader"]:
|
||||
_expect_true(
|
||||
not grouper_source.contains(forbidden_dependency),
|
||||
"grouper omits %s" % forbidden_dependency,
|
||||
failures
|
||||
)
|
||||
|
||||
|
||||
func _verify_bounded_timing(failures: Array[String]) -> float:
|
||||
var grouper := GROUPER_SCRIPT.new()
|
||||
var names := PackedStringArray(["Tree.m2", "Rock.mdx"])
|
||||
var placements: Array = []
|
||||
for placement_index in range(256):
|
||||
placements.append({
|
||||
"name_id": placement_index % 2,
|
||||
"pos": Vector3(float(placement_index), 0.0, float(-placement_index)),
|
||||
"rot": Vector3(0.1, 0.2, 0.3),
|
||||
"scale": 1.0,
|
||||
})
|
||||
var started_microseconds := Time.get_ticks_usec()
|
||||
for iteration in range(100):
|
||||
var groups: Dictionary = grouper.group_placements(Vector3.ZERO, names, placements)
|
||||
if groups.size() != 2:
|
||||
failures.append("timing iteration %d produced wrong group count" % iteration)
|
||||
break
|
||||
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
|
||||
_expect_true(elapsed_milliseconds < 1000.0, "100 x 256 grouping bounded", failures)
|
||||
return elapsed_milliseconds
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _source_between(
|
||||
source: String,
|
||||
start_marker: String,
|
||||
end_marker: String,
|
||||
failures: Array[String]
|
||||
) -> String:
|
||||
var start_offset := source.find(start_marker)
|
||||
var end_offset := source.find(end_marker, start_offset + start_marker.length())
|
||||
if start_offset < 0 or end_offset <= start_offset:
|
||||
failures.append("cannot isolate source between %s and %s" % [start_marker, end_marker])
|
||||
return ""
|
||||
return source.substr(start_offset, end_offset - start_offset)
|
||||
|
||||
|
||||
func _expect_basis_near(
|
||||
actual_basis: Basis,
|
||||
expected_basis: Basis,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if not actual_basis.is_equal_approx(expected_basis):
|
||||
failures.append("%s basis mismatch" % label)
|
||||
|
||||
|
||||
func _expect_vector_near(
|
||||
actual_value: Vector3,
|
||||
expected_value: Vector3,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if not actual_value.is_equal_approx(expected_value):
|
||||
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_equal(
|
||||
actual_value: int,
|
||||
expected_value: int,
|
||||
label: String,
|
||||
failures: Array[String]
|
||||
) -> void:
|
||||
if actual_value != expected_value:
|
||||
failures.append("%s expected %d, got %d" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbx57qy0lp11w
|
||||
@@ -4,6 +4,7 @@ extends SceneTree
|
||||
|
||||
const RESOLVER_SCRIPT := preload("res://src/render/m2/m2_placement_transform_resolver.gd")
|
||||
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
|
||||
const GROUPER_PATH := "res://src/render/m2/m2_placement_grouper.gd"
|
||||
const WATERFALL_WORLD_YAW_OFFSET := PI * 0.5
|
||||
const CLIFFROCK_WORLD_YAW_OFFSET := PI
|
||||
|
||||
@@ -130,6 +131,7 @@ func _verify_path_normalization_and_scale_clamp(failures: Array[String]) -> void
|
||||
|
||||
func _verify_loader_boundary(failures: Array[String]) -> void:
|
||||
var source := _read_text(LOADER_PATH, failures)
|
||||
var grouper_source := _read_text(GROUPER_PATH, failures)
|
||||
_expect_true(
|
||||
source.contains("M2_PLACEMENT_TRANSFORM_RESOLVER_SCRIPT.new()"),
|
||||
"loader composes resolver",
|
||||
@@ -137,14 +139,26 @@ func _verify_loader_boundary(failures: Array[String]) -> void:
|
||||
)
|
||||
_expect_equal(
|
||||
source.count("_m2_placement_transform_resolver.resolve_basis("),
|
||||
3,
|
||||
"three basis adapters",
|
||||
2,
|
||||
"two direct loader basis adapters",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
source.count("_m2_placement_transform_resolver.resolve_origin_offset("),
|
||||
3,
|
||||
"three offset adapters",
|
||||
2,
|
||||
"two direct loader offset adapters",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_basis("),
|
||||
1,
|
||||
"one grouped basis adapter",
|
||||
failures
|
||||
)
|
||||
_expect_equal(
|
||||
grouper_source.count("placement_transform_resolver.resolve_origin_offset("),
|
||||
1,
|
||||
"one grouped offset adapter",
|
||||
failures
|
||||
)
|
||||
_expect_true(
|
||||
|
||||
Reference in New Issue
Block a user