272 lines
8.6 KiB
GDScript
272 lines
8.6 KiB
GDScript
@tool
|
|
class_name DiggableArea3D
|
|
extends Node3D
|
|
|
|
const SURFACE_HEIGHT_TOLERANCE := 0.015
|
|
const SURFACE_BOUNDS_TOLERANCE := 0.001
|
|
const MINIMUM_VALIDATION_UP_DOT := 0.01
|
|
|
|
@export var area_id: StringName
|
|
@export_node_path("Node3D") var terrain_source: NodePath
|
|
@export var surface_materials: Array[StringName] = []
|
|
@export var generation_bounds := Rect2(-50.0, -50.0, 100.0, 100.0)
|
|
@export_range(-100.0, 100.0, 0.01) var minimum_global_y: float = -100.0
|
|
@export_range(-100.0, 100.0, 0.01) var maximum_global_y: float = 100.0
|
|
@export_range(0.0, 1.0, 0.01) var minimum_up_dot: float = 0.6
|
|
|
|
var _surface_validation_groups: Array[Dictionary] = []
|
|
var _surface_validation_ready := false
|
|
|
|
|
|
func get_surface_triangles() -> Array[PackedVector3Array]:
|
|
var triangles: Array[PackedVector3Array] = []
|
|
_surface_validation_groups.clear()
|
|
_surface_validation_ready = false
|
|
if area_id.is_empty() or surface_materials.is_empty():
|
|
return triangles
|
|
var terrain_root: Node = get_node_or_null(terrain_source)
|
|
if terrain_root == null:
|
|
return triangles
|
|
for mesh_instance: MeshInstance3D in _terrain_mesh_instances(terrain_root):
|
|
var mesh: Mesh = mesh_instance.mesh
|
|
if mesh == null:
|
|
continue
|
|
var validation_records: Array[Dictionary] = []
|
|
var validation_bounds := Rect2()
|
|
var has_validation_bounds := false
|
|
for surface_index: int in mesh.get_surface_count():
|
|
var material: Material = mesh_instance.get_active_material(surface_index)
|
|
var allowed_surface := (
|
|
material != null
|
|
and surface_materials.has(StringName(material.resource_name))
|
|
)
|
|
_append_surface_triangles(
|
|
triangles,
|
|
validation_records,
|
|
mesh_instance,
|
|
mesh.surface_get_arrays(surface_index),
|
|
allowed_surface,
|
|
)
|
|
for record: Dictionary in validation_records:
|
|
var record_bounds: Rect2 = record.get("bounds", Rect2())
|
|
if not has_validation_bounds:
|
|
validation_bounds = record_bounds
|
|
has_validation_bounds = true
|
|
else:
|
|
validation_bounds = validation_bounds.merge(record_bounds)
|
|
if not validation_records.is_empty():
|
|
_surface_validation_groups.append({
|
|
"bounds": validation_bounds,
|
|
"records": validation_records,
|
|
})
|
|
_surface_validation_ready = true
|
|
return triangles
|
|
|
|
|
|
func invalidate_surface_cache() -> void:
|
|
_surface_validation_groups.clear()
|
|
_surface_validation_ready = false
|
|
|
|
|
|
func is_surface_point_valid(point: Vector3) -> bool:
|
|
if not point.is_finite():
|
|
return false
|
|
if not _surface_validation_ready:
|
|
get_surface_triangles()
|
|
var sample := Vector2(point.x, point.z)
|
|
var highest_y := -INF
|
|
var highest_is_allowed := false
|
|
var found_surface := false
|
|
for group: Dictionary in _surface_validation_groups:
|
|
var group_bounds: Rect2 = group.get("bounds", Rect2())
|
|
if not group_bounds.grow(SURFACE_BOUNDS_TOLERANCE).has_point(sample):
|
|
continue
|
|
var records: Array = group.get("records", [])
|
|
for record: Dictionary in records:
|
|
var record_bounds: Rect2 = record.get("bounds", Rect2())
|
|
if not record_bounds.grow(SURFACE_BOUNDS_TOLERANCE).has_point(sample):
|
|
continue
|
|
var triangle := record.get(
|
|
"triangle", PackedVector3Array()
|
|
) as PackedVector3Array
|
|
var surface_y := _surface_height_at_point(triangle, sample)
|
|
if not is_finite(surface_y):
|
|
continue
|
|
var allowed := bool(record.get("allowed", false))
|
|
if surface_y > highest_y + SURFACE_HEIGHT_TOLERANCE:
|
|
highest_y = surface_y
|
|
highest_is_allowed = allowed
|
|
found_surface = true
|
|
elif absf(surface_y - highest_y) <= SURFACE_HEIGHT_TOLERANCE:
|
|
# Material seams may expose coplanar grass and sand triangles.
|
|
# Treat any tied disallowed surface as authoritative so a dig
|
|
# hotspot can never appear in visually grassy terrain.
|
|
highest_is_allowed = highest_is_allowed and allowed
|
|
return (
|
|
found_surface
|
|
and highest_is_allowed
|
|
and absf(highest_y - point.y) <= SURFACE_HEIGHT_TOLERANCE
|
|
)
|
|
|
|
|
|
func _append_surface_triangles(
|
|
result: Array[PackedVector3Array],
|
|
validation_records: Array[Dictionary],
|
|
mesh_instance: MeshInstance3D,
|
|
arrays: Array,
|
|
allowed_surface: bool,
|
|
) -> void:
|
|
if arrays.size() <= Mesh.ARRAY_INDEX:
|
|
return
|
|
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
|
|
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
|
|
if vertices.is_empty():
|
|
return
|
|
if indices.is_empty():
|
|
for vertex_index: int in range(0, vertices.size() - 2, 3):
|
|
_append_triangle(
|
|
result,
|
|
validation_records,
|
|
mesh_instance.to_global(vertices[vertex_index]),
|
|
mesh_instance.to_global(vertices[vertex_index + 1]),
|
|
mesh_instance.to_global(vertices[vertex_index + 2]),
|
|
allowed_surface,
|
|
)
|
|
return
|
|
for index_offset: int in range(0, indices.size() - 2, 3):
|
|
_append_triangle(
|
|
result,
|
|
validation_records,
|
|
mesh_instance.to_global(vertices[indices[index_offset]]),
|
|
mesh_instance.to_global(vertices[indices[index_offset + 1]]),
|
|
mesh_instance.to_global(vertices[indices[index_offset + 2]]),
|
|
allowed_surface,
|
|
)
|
|
|
|
|
|
func _append_triangle(
|
|
result: Array[PackedVector3Array],
|
|
validation_records: Array[Dictionary],
|
|
a: Vector3,
|
|
b: Vector3,
|
|
c: Vector3,
|
|
allowed_surface: bool,
|
|
) -> void:
|
|
var cross := (b - a).cross(c - a)
|
|
if cross.length_squared() <= 0.0000001:
|
|
return
|
|
var up_dot := absf(cross.normalized().dot(Vector3.UP))
|
|
var triangle := PackedVector3Array([a, b, c])
|
|
var projected_bounds := _projected_bounds(triangle)
|
|
if (
|
|
up_dot >= MINIMUM_VALIDATION_UP_DOT
|
|
and generation_bounds.intersects(projected_bounds, true)
|
|
):
|
|
validation_records.append({
|
|
"allowed": allowed_surface,
|
|
"bounds": projected_bounds,
|
|
"triangle": triangle,
|
|
})
|
|
if not allowed_surface:
|
|
return
|
|
if (
|
|
a.y < minimum_global_y
|
|
or b.y < minimum_global_y
|
|
or c.y < minimum_global_y
|
|
or a.y > maximum_global_y
|
|
or b.y > maximum_global_y
|
|
or c.y > maximum_global_y
|
|
):
|
|
return
|
|
var center := (a + b + c) / 3.0
|
|
if not generation_bounds.has_point(Vector2(center.x, center.z)):
|
|
return
|
|
if up_dot < minimum_up_dot:
|
|
return
|
|
result.append(triangle)
|
|
|
|
|
|
static func _projected_bounds(triangle: PackedVector3Array) -> Rect2:
|
|
var minimum := Vector2(triangle[0].x, triangle[0].z)
|
|
var maximum := minimum
|
|
for index: int in range(1, triangle.size()):
|
|
var point := Vector2(triangle[index].x, triangle[index].z)
|
|
minimum = minimum.min(point)
|
|
maximum = maximum.max(point)
|
|
return Rect2(minimum, maximum - minimum)
|
|
|
|
|
|
static func _surface_height_at_point(
|
|
triangle: PackedVector3Array,
|
|
point: Vector2,
|
|
) -> float:
|
|
if triangle.size() != 3:
|
|
return INF
|
|
var a := Vector2(triangle[0].x, triangle[0].z)
|
|
var b := Vector2(triangle[1].x, triangle[1].z)
|
|
var c := Vector2(triangle[2].x, triangle[2].z)
|
|
var denominator := (
|
|
(b.y - c.y) * (a.x - c.x)
|
|
+ (c.x - b.x) * (a.y - c.y)
|
|
)
|
|
if absf(denominator) <= 0.0000001:
|
|
return INF
|
|
var weight_a := (
|
|
(b.y - c.y) * (point.x - c.x)
|
|
+ (c.x - b.x) * (point.y - c.y)
|
|
) / denominator
|
|
var weight_b := (
|
|
(c.y - a.y) * (point.x - c.x)
|
|
+ (a.x - c.x) * (point.y - c.y)
|
|
) / denominator
|
|
var weight_c := 1.0 - weight_a - weight_b
|
|
if (
|
|
weight_a < -SURFACE_BOUNDS_TOLERANCE
|
|
or weight_b < -SURFACE_BOUNDS_TOLERANCE
|
|
or weight_c < -SURFACE_BOUNDS_TOLERANCE
|
|
):
|
|
return INF
|
|
return (
|
|
triangle[0].y * weight_a
|
|
+ triangle[1].y * weight_b
|
|
+ triangle[2].y * weight_c
|
|
)
|
|
|
|
|
|
func _terrain_mesh_instances(terrain_root: Node) -> Array[MeshInstance3D]:
|
|
# Generated terrain can contain authored base layers beneath raised visual
|
|
# overlays. Those meshes are useful for closing terrain seams, but they are
|
|
# not necessarily the visible surface and must not produce buried dig spots.
|
|
# A terrain provider may therefore expose its authoritative primary meshes.
|
|
if terrain_root.has_method(&"get_primary_terrain_meshes"):
|
|
var provided: Variant = terrain_root.call(&"get_primary_terrain_meshes")
|
|
var meshes: Array[MeshInstance3D] = []
|
|
if provided is Array:
|
|
for value: Variant in provided:
|
|
var mesh_instance := value as MeshInstance3D
|
|
if mesh_instance != null:
|
|
meshes.append(mesh_instance)
|
|
return meshes
|
|
return _collect_mesh_instances(terrain_root)
|
|
|
|
|
|
func _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]:
|
|
var meshes: Array[MeshInstance3D] = []
|
|
if root is MeshInstance3D:
|
|
meshes.append(root as MeshInstance3D)
|
|
for child: Node in root.get_children():
|
|
meshes.append_array(_collect_mesh_instances(child))
|
|
return meshes
|
|
|
|
|
|
func _get_configuration_warnings() -> PackedStringArray:
|
|
var warnings := PackedStringArray()
|
|
if area_id.is_empty():
|
|
warnings.append("Diggable area ID is required.")
|
|
if get_node_or_null(terrain_source) == null:
|
|
warnings.append("Diggable area terrain source is unavailable.")
|
|
if surface_materials.is_empty():
|
|
warnings.append("At least one terrain material is required.")
|
|
if maximum_global_y < minimum_global_y:
|
|
warnings.append("Maximum height must not be below minimum height.")
|
|
return warnings
|