184 lines
6.5 KiB
GDScript
184 lines
6.5 KiB
GDScript
class_name PlacementStackManager
|
|
extends RefCounted
|
|
## Manages 3D surface collision, stacking calculations, AABB normalization,
|
|
## and hierarchical parenting for world objects placed on top of other objects.
|
|
##
|
|
## Coordinate convention in Chunk data:
|
|
## X = horizontal X
|
|
## Y = horizontal Y (ground Y in chunk grid)
|
|
## Z = vertical height (CHUNK_SIZE_Z elevation)
|
|
##
|
|
## In Godot 3D:
|
|
## Vector3(X, Z_height, Y)
|
|
|
|
## Known default bounding boxes for catalog items (size and base Y offset)
|
|
const DEFAULT_BOUNDS: Dictionary = {
|
|
"crate_small": {"size": Vector3(1.0, 1.0, 1.0), "base_offset": 0.0},
|
|
"crate_large": {"size": Vector3(2.0, 2.0, 2.0), "base_offset": 0.0},
|
|
"chest_01": {"size": Vector3(1.0, 0.6, 0.8), "base_offset": 0.0},
|
|
"chest": {"size": Vector3(1.0, 0.6, 0.8), "base_offset": 0.0},
|
|
"buffalo_chip_coin": {"size": Vector3(0.4, 0.4, 0.1), "base_offset": 0.0},
|
|
"table_wooden": {"size": Vector3(1.8, 1.2, 0.9), "base_offset": 0.0},
|
|
"table": {"size": Vector3(1.8, 1.2, 0.9), "base_offset": 0.0},
|
|
"chair": {"size": Vector3(0.6, 0.6, 0.9), "base_offset": 0.0},
|
|
"rock_01": {"size": Vector3(1.2, 1.2, 0.8), "base_offset": 0.0},
|
|
"lamp_post_01": {"size": Vector3(0.5, 0.5, 3.0), "base_offset": 0.0},
|
|
"bison": {"size": Vector3(1.5, 2.4, 1.8), "base_offset": 0.0},
|
|
"default": {"size": Vector3(1.0, 1.0, 1.0), "base_offset": 0.0},
|
|
}
|
|
|
|
|
|
## Return the 3D dimensions of an object by catalog_id.
|
|
## Note: Returned Vector3 uses (width_x, depth_y, height_z) matching chunk coordinates.
|
|
static func get_dimensions(catalog_id: String, scale: Vector3 = Vector3.ONE) -> Vector3:
|
|
var info: Dictionary = DEFAULT_BOUNDS.get("default")
|
|
var key: String = catalog_id.to_lower()
|
|
for k in DEFAULT_BOUNDS.keys():
|
|
if key.contains(k):
|
|
info = DEFAULT_BOUNDS[k]
|
|
break
|
|
var s: Vector3 = info["size"]
|
|
return Vector3(s.x * scale.x, s.y * scale.y, s.z * scale.z)
|
|
|
|
|
|
## Return the vertical height of an object by catalog_id.
|
|
static func get_height(catalog_id: String, scale_z: float = 1.0) -> float:
|
|
return get_dimensions(catalog_id, Vector3(1.0, 1.0, scale_z)).z
|
|
|
|
|
|
## Compute stacked transform when placing an item on top of another object.
|
|
## Returns Dictionary with { "position": Vector3, "rotation": Vector4, "parent_id": String, "is_stacked": bool }
|
|
static func calculate_stack_transform(
|
|
catalog_id: String,
|
|
target_obj: ChunkDataDecoder.WorldObjectData,
|
|
hit_point: Vector3,
|
|
surface_normal: Vector3 = Vector3(0, 0, 1),
|
|
elevation_offset: float = 0.0,
|
|
align_to_normal: bool = false
|
|
) -> Dictionary:
|
|
if target_obj == null:
|
|
return {
|
|
"position": Vector3(hit_point.x, hit_point.y, maxf(0.0, hit_point.z + elevation_offset)),
|
|
"rotation": Vector4(0, 0, 0, 1),
|
|
"parent_id": "",
|
|
"is_stacked": false,
|
|
}
|
|
|
|
var target_cat_id: String = ""
|
|
if target_obj.properties is Dictionary and target_obj.properties.has("catalog_id"):
|
|
target_cat_id = str(target_obj.properties["catalog_id"])
|
|
else:
|
|
target_cat_id = target_obj.asset_id.get_file().get_basename()
|
|
|
|
var target_height: float = get_height(target_cat_id, target_obj.scale.z)
|
|
var stack_top_z: float = target_obj.position.z + target_height + elevation_offset
|
|
|
|
var rot := Vector4(0, 0, 0, 1)
|
|
if align_to_normal and surface_normal != Vector3(0, 0, 1) and surface_normal != Vector3.ZERO:
|
|
# Calculate rotation aligning Z (up in chunk coords) to surface_normal
|
|
var up := Vector3(0, 0, 1)
|
|
var q := Quaternion(up, surface_normal.normalized())
|
|
rot = Vector4(q.x, q.y, q.z, q.w)
|
|
|
|
return {
|
|
"position": Vector3(hit_point.x, hit_point.y, stack_top_z),
|
|
"rotation": rot,
|
|
"parent_id": target_obj.id,
|
|
"is_stacked": true,
|
|
}
|
|
|
|
|
|
## Find the highest underlying object beneath an (x, y) footprint at or below current_z.
|
|
static func find_underlying_object(
|
|
chunk_model: ChunkEditorModel,
|
|
x: float,
|
|
y: float,
|
|
current_z: float,
|
|
exclude_id: String = ""
|
|
) -> Dictionary:
|
|
var best_obj: ChunkDataDecoder.WorldObjectData = null
|
|
var highest_top_z: float = 0.0
|
|
|
|
for obj in chunk_model.payload.objects:
|
|
if obj.id == exclude_id:
|
|
continue
|
|
|
|
var cat_id: String = ""
|
|
if obj.properties is Dictionary and obj.properties.has("catalog_id"):
|
|
cat_id = str(obj.properties["catalog_id"])
|
|
else:
|
|
cat_id = obj.asset_id.get_file().get_basename()
|
|
|
|
var dims := get_dimensions(cat_id, obj.scale)
|
|
var half_w := dims.x * 0.5
|
|
var half_d := dims.y * 0.5
|
|
var min_x := obj.position.x - half_w
|
|
var max_x := obj.position.x + half_w
|
|
var min_y := obj.position.y - half_d
|
|
var max_y := obj.position.y + half_d
|
|
var top_z := obj.position.z + dims.z
|
|
|
|
# Check if (x, y) falls within this object's horizontal footprint
|
|
if x >= min_x and x <= max_x and y >= min_y and y <= max_y:
|
|
if top_z <= current_z + 0.01:
|
|
if top_z >= highest_top_z:
|
|
highest_top_z = top_z
|
|
best_obj = obj
|
|
|
|
return {
|
|
"object": best_obj,
|
|
"surface_z": highest_top_z,
|
|
}
|
|
|
|
|
|
## Drop an object straight downward until it sits flush on top of the surface below it.
|
|
static func drop_to_surface(chunk_model: ChunkEditorModel, object_id: String) -> bool:
|
|
var obj: ChunkDataDecoder.WorldObjectData = chunk_model.find_object(object_id)
|
|
if obj == null:
|
|
return false
|
|
|
|
var result := find_underlying_object(chunk_model, obj.position.x, obj.position.y, obj.position.z, object_id)
|
|
var new_z: float = result["surface_z"]
|
|
var target_parent: ChunkDataDecoder.WorldObjectData = result["object"]
|
|
|
|
obj.position.z = new_z
|
|
if target_parent != null:
|
|
obj.properties["parent_id"] = target_parent.id
|
|
obj.properties["stacked_on"] = target_parent.id
|
|
else:
|
|
obj.properties.erase("parent_id")
|
|
obj.properties.erase("stacked_on")
|
|
|
|
return true
|
|
|
|
|
|
## Propagate parent movement to all recursively stacked child objects.
|
|
static func propagate_parent_delta(
|
|
chunk_model: ChunkEditorModel,
|
|
parent_id: String,
|
|
delta: Vector3
|
|
) -> Array[String]:
|
|
var moved_ids: Array[String] = []
|
|
for obj in chunk_model.payload.objects:
|
|
if obj.properties is Dictionary and obj.properties.get("parent_id") == parent_id:
|
|
obj.position += delta
|
|
moved_ids.append(obj.id)
|
|
var sub_children := propagate_parent_delta(chunk_model, obj.id, delta)
|
|
for sc in sub_children:
|
|
if not sc in moved_ids:
|
|
moved_ids.append(sc)
|
|
return moved_ids
|
|
|
|
|
|
## Find all direct or indirect children of a parent object.
|
|
static func get_all_children(chunk_model: ChunkEditorModel, parent_id: String) -> Array[String]:
|
|
var result: Array[String] = []
|
|
for obj in chunk_model.payload.objects:
|
|
if obj.properties is Dictionary and obj.properties.get("parent_id") == parent_id:
|
|
result.append(obj.id)
|
|
var sub := get_all_children(chunk_model, obj.id)
|
|
for s in sub:
|
|
if not s in result:
|
|
result.append(s)
|
|
return result
|