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