@tool class_name CSGSpreader3D extends CSGCombiner3D const SPREADER_NODE_META = "SPREADER_NODE_META" const MAX_INSTANCES = 200 const REGEN_THROTTLE_MS: int = 150 var _dirty: bool = false var _last_regen_ms: int = 0 var _generation_in_progress := false var _template_node: Node3D @export var template_node: Node3D: get: return _template_node set(value): _template_node = value _mark_dirty() var _hide_template: bool = true @export var hide_template: bool = true: get: return _hide_template set(value): _hide_template = value _update_template_visibility() var _spread_area_3d: Shape3D = null @export var spread_area_3d: Shape3D = null: get: return _spread_area_3d set(value): _spread_area_3d = value _mark_dirty() var _max_count: int = 10 @export var max_count: int = 10: get: return _max_count set(value): _max_count = clamp(value, 1, 100000) _mark_dirty() @export_group("Spread Options") var _noise_threshold: float = 0.5 @export var noise_threshold: float = 0.5: get: return _noise_threshold set(value): _noise_threshold = clamp(value, 0.0, 1.0) _mark_dirty() var _seed: int = 0 @export var seed: int = 0: get: return _seed set(value): _seed = value _mark_dirty() var _allow_rotation: bool = false @export var allow_rotation: bool = false: get: return _allow_rotation set(value): _allow_rotation = value _mark_dirty() var _allow_scale: bool = false @export var allow_scale: bool = false: get: return _allow_scale set(value): _allow_scale = value _mark_dirty() var _snap_distance: float = 0.0 @export var snap_distance: float = 0.0: get: return _snap_distance set(value): _snap_distance = value _mark_dirty() @export_group("Collision Options") var _avoid_overlaps: bool = false @export var avoid_overlaps: bool = false: get: return _avoid_overlaps set(value): _avoid_overlaps = value _mark_dirty() var _min_distance: float = 1.0 @export var min_distance: float = 1.0: get: return _min_distance set(value): _min_distance = max(0.0, value) _mark_dirty() var _max_placement_attempts: int = 100 @export var max_placement_attempts: int = 100: get: return _max_placement_attempts set(value): _max_placement_attempts = clamp(value, 10, 1000) _mark_dirty() var estimated_instances: int = 0 var rng: RandomNumberGenerator func _ready() -> void: rng = RandomNumberGenerator.new() _mark_dirty() func _process(_delta: float) -> void: if not Engine.is_editor_hint(): return if _dirty and not _generation_in_progress: var now := Time.get_ticks_msec() if now - _last_regen_ms < REGEN_THROTTLE_MS: return # 保留 _dirty,下帧再试,形成节流 _last_regen_ms = now _dirty = false call_deferred("spread_template") func _exit_tree() -> void: if not Engine.is_editor_hint(): return clear_children() func _mark_dirty() -> void: _dirty = true func _update_template_visibility() -> void: if not is_inside_tree(): return if is_instance_valid(_template_node): _template_node.visible = not _hide_template func clear_children() -> void: var children_to_remove: Array[Node] = [] for child in get_children(): if child.has_meta(SPREADER_NODE_META): children_to_remove.append(child) for child in children_to_remove: remove_child(child) child.queue_free() func get_random_position_in_area() -> Vector3: if spread_area_3d == null: return Vector3.ZERO if spread_area_3d is SphereShape3D: var radius = spread_area_3d.get_radius() var u = rng.randf() var v = rng.randf() var theta = u * TAU var phi = acos(2.0 * v - 1.0) var r = radius * pow(rng.randf(), 1.0 / 3.0) return Vector3(r * sin(phi) * cos(theta), r * sin(phi) * sin(theta), r * cos(phi)) if spread_area_3d is BoxShape3D: var size = spread_area_3d.size return Vector3( rng.randf_range(-size.x * 0.5, size.x * 0.5), rng.randf_range(-size.y * 0.5, size.y * 0.5), rng.randf_range(-size.z * 0.5, size.z * 0.5) ) if spread_area_3d is CapsuleShape3D: var capsule: CapsuleShape3D = spread_area_3d var radius: float = capsule.radius var half_mid: float = maxf(0.0, capsule.height - 2.0 * capsule.radius) * 0.5 if rng.randf() < 0.5: var angle: float = rng.randf() * TAU var r: float = radius * sqrt(rng.randf()) return Vector3(r * cos(angle), rng.randf_range(-half_mid, half_mid), r * sin(angle)) var hemisphere_y: float = half_mid if rng.randf() < 0.5 else -half_mid var theta: float = rng.randf() * TAU var phi: float = acos(1.0 - rng.randf()) var cap_r: float = radius * pow(rng.randf(), 1.0 / 3.0) var cap_sign: float = 1.0 if hemisphere_y > 0.0 else -1.0 return Vector3( cap_r * sin(phi) * cos(theta), hemisphere_y + cap_r * cos(phi) * cap_sign, cap_r * sin(phi) * sin(theta) ) if spread_area_3d is CylinderShape3D: var radius = spread_area_3d.get_radius() var height = spread_area_3d.get_height() * 0.5 var angle = rng.randf() * TAU var r = radius * sqrt(rng.randf()) return Vector3(r * cos(angle), rng.randf_range(-height, height), r * sin(angle)) if spread_area_3d is HeightMapShape3D: var width = spread_area_3d.map_width var depth = spread_area_3d.map_depth if width <= 0 or depth <= 0 or spread_area_3d.map_data.size() == 0: return Vector3.ZERO var x = rng.randi_range(0, width - 1) var z = rng.randi_range(0, depth - 1) var index = x + z * width if index < spread_area_3d.map_data.size(): return Vector3(x, spread_area_3d.map_data[index], z) return Vector3.ZERO if spread_area_3d is WorldBoundaryShape3D: var bound = 100.0 return Vector3(rng.randf_range(-bound, bound), 0, rng.randf_range(-bound, bound)) if spread_area_3d is ConcavePolygonShape3D: var faces: PackedVector3Array = spread_area_3d.get_faces() if faces.size() < 3: return Vector3.ZERO var tri_count = faces.size() / 3 var tri_idx = rng.randi_range(0, tri_count - 1) * 3 var a = faces[tri_idx] var b = faces[tri_idx+1] var c = faces[tri_idx+2] var r1 = sqrt(rng.randf()) var r2 = rng.randf() return (1.0 - r1) * a + (r1 * (1.0 - r2)) * b + (r1 * r2) * c if spread_area_3d is ConvexPolygonShape3D: var pts: PackedVector3Array = spread_area_3d.points if pts.is_empty(): return Vector3.ZERO var min_point = pts[0] var max_point = pts[0] for p in pts: min_point = min_point.min(p) max_point = max_point.max(p) return Vector3( rng.randf_range(min_point.x, max_point.x), rng.randf_range(min_point.y, max_point.y), rng.randf_range(min_point.z, max_point.z) ) push_warning("CSGSpreader3D: Shape type not supported") return Vector3.ZERO func spread_template() -> void: if _generation_in_progress: return _generation_in_progress = true if not spread_area_3d: _generation_in_progress = false return clear_children() if not is_instance_valid(_template_node): _generation_in_progress = false return var template_node: Node3D = _template_node rng.seed = _seed var instances_created: int = 0 var placed_positions: Array[Vector3] = [] var budget: int = min(_max_count, MAX_INSTANCES) if _max_count > MAX_INSTANCES: push_warning("CSGSpreader3D: max_count %s exceeds cap %s. Limiting." % [_max_count, MAX_INSTANCES]) # Spatial Hash Grid for O(1) distance checks # Cell edge must be >= min_distance so any point within min_distance of a # candidate is always inside the +/-1 neighborhood (27 cells). var cell_size: float = maxf(_min_distance, 0.001) var spatial_grid: Dictionary = {} # Vector3i -> Array[Vector3] for i in range(budget): var noise_value: float = rng.randf() if noise_value <= _noise_threshold: continue var position_found: bool = false var final_position: Vector3 = Vector3.ZERO var attempts: int = _max_placement_attempts if _avoid_overlaps else 1 for attempt in range(attempts): var test_position: Vector3 = get_random_position_in_area() if not _avoid_overlaps: final_position = test_position position_found = true break var overlap: bool = false if cell_size > 0.001: # O(1) spatial grid lookup var grid_x: int = int(floor(test_position.x / cell_size)) var grid_y: int = int(floor(test_position.y / cell_size)) var grid_z: int = int(floor(test_position.z / cell_size)) # Check surrounding 27 cells (each cell holds all positions in it) for dx in range(-1, 2): for dy in range(-1, 2): for dz in range(-1, 2): var cell_key := Vector3i(grid_x + dx, grid_y + dy, grid_z + dz) if not spatial_grid.has(cell_key): continue for existing_pos: Vector3 in spatial_grid[cell_key]: if test_position.distance_to(existing_pos) < _min_distance: overlap = true break if overlap: break if overlap: break if overlap: break else: # Fallback if min_distance is virtually zero for existing_pos in placed_positions: if test_position.distance_to(existing_pos) < _min_distance: overlap = true break if not overlap: final_position = test_position position_found = true break if not position_found: continue var instance: Node = template_node.duplicate() if instance == null: continue instance.set_meta(SPREADER_NODE_META, true) if instance is Node3D: instance.transform.origin = final_position instance.visible = true if _allow_rotation: var rotation_y: float = rng.randf_range(0.0, TAU) instance.rotate_y(rotation_y) if _allow_scale: var scale_factor: float = rng.randf_range(0.5, 2.0) instance.scale *= scale_factor placed_positions.append(final_position) if _avoid_overlaps and cell_size > 0.001: var key := Vector3i( int(floor(final_position.x / cell_size)), int(floor(final_position.y / cell_size)), int(floor(final_position.z / cell_size)) ) var cell_list: Array[Vector3] = [] if spatial_grid.has(key): cell_list = spatial_grid[key] cell_list.append(final_position) spatial_grid[key] = cell_list add_child(instance) instances_created += 1 estimated_instances = instances_created _update_template_visibility() _generation_in_progress = false func bake_instances() -> void: if get_child_count() == 0: return var target_owner: Node = owner if target_owner == null: if get_tree() and get_tree().edited_scene_root: target_owner = get_tree().edited_scene_root else: target_owner = self var baked: Array[Node] = [] for child in get_children(true): if child.has_meta(SPREADER_NODE_META): baked.append(child) var stack: Array[Node] = [] stack.append_array(child.get_children()) while stack.size() > 0: var node: Node = stack.pop_back() baked.append(node) stack.append_array(node.get_children()) if baked.is_empty(): return if Engine.is_editor_hint() and CsgBlockout.undo_manager: var um: EditorUndoRedoManager = CsgBlockout.undo_manager um.create_action(CsgBlockoutI18n.t("BAKE")) for node in baked: um.add_undo_reference(node) um.add_do_property(node, "owner", target_owner) um.add_undo_property(node, "owner", node.owner) um.add_do_method(self, "_bake_clear_meta", baked) um.add_undo_method(self, "_bake_restore_meta", baked) um.commit_action() else: for node in baked: node.set_owner(target_owner) _bake_clear_meta(baked) func _bake_clear_meta(nodes: Array[Node]) -> void: for node in nodes: if is_instance_valid(node): node.remove_meta(SPREADER_NODE_META) func _bake_restore_meta(nodes: Array[Node]) -> void: for node in nodes: if is_instance_valid(node): node.set_meta(SPREADER_NODE_META, true) func _get_property_list() -> Array[Dictionary]: return [{ "name": "estimated_instances", "type": TYPE_INT, "usage": PROPERTY_USAGE_EDITOR | PROPERTY_USAGE_STORAGE | PROPERTY_USAGE_READ_ONLY }]