feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s

This commit is contained in:
2026-09-22 18:30:57 +00:00
parent 654c1ec6bf
commit 721dbe5ae7
37 changed files with 5202 additions and 17 deletions
+183
View File
@@ -0,0 +1,183 @@
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