Build CollisionShape in gdscript tool

Godot Version

Godot Engine v4.6.stable

Question

Hi,
Is it possible to build and setup CollisionShapes (or any other node) from variable in gdscript and see the result in the editor?

I have a GravityVolumes class built from Area3D, and I would like to build it directly as a Sphere

@tool
class_name GravityVolume
extends Area3D

@export var radius: float = 5.0:
	set(new_value):
		radius = new_value
		construct_collision_shape()

var collision_shape: CollisionShape3D

func construct_collision_shape() -> void:
	print("construct_collision")
	# Add Sphere collision shape
	if !collision_shape:
		collision_shape = CollisionShape3D.new()
		#var shape := SphereShape3D.new()
		collision_shape.shape = SphereShape3D.new()
		add_child(collision_shape) 
		print("collision shape added")
	collision_shape.shape.radius = radius
		

I would like my sphere collision radius to match the radius already setup in my GravityVolume class and not having to set it twice.

I also tried calling the function from _enter_tree_(), _init() or _ready(), but it doesn’t help.

func _enter_tree() -> void:
	construct_collision_shape()
	print("_enter_tree called")
	
func _init() -> void:
	construct_collision_shape()
	print("_init called")

func _ready() -> void:
	construct_collision_shape()
	print("_ready called")

Is there something similar to the construction script in Unreal Engine?

Hi, there are some things you need to do in order to get nodes instanced through tool scripts to show up.

By default, nodes or scenes added with Node.add_child(node) are not visible in the Scene tree dock and are not persisted to disk. If you wish the node or scene to be visible in the scene tree dock and persisted to disk when saving the scene, you need to set the child node’s owner property to the currently edited scene root.

1 Like

You totally can. This is just a snippet from a terrain generator:

#	Add StaticBody3d
var static_body : StaticBody3D = StaticBody3D.new()

static_body.collision_layer = terrain_collision_layer

mesh_instance.add_child(static_body) 
static_body.owner = mesh_instance.owner
static_body.set_as_top_level(true)
#	Create collision sahpe based on the terrain mesh
var trimesh_shape : ConcavePolygonShape3D = array_mesh.create_trimesh_shape()
var collision_shape : CollisionShape3D = CollisionShape3D.new()
collision_shape.shape = trimesh_shape
#	Add the collision shape
static_body.add_child(collision_shape)
collision_shape.owner = static_body.owner
2 Likes

Thank you for your answers.
I added the line to set the owner, it partially solve my issue.

I was initially confused on how to get it to work, but I finally noticed that I actually have another problem. This condition seems to prevent my code from being executed:

if !collision_shape:

It is to check if my collision already has been built. Isn’t that variable supposed to be null? (I already tried explicitly assigning null value, it doesn’t change anything)

var collision_shape: CollisionShape3D

I don’t get what I’m doing wrong.
Here is all the code:

@tool
class_name GravityVolume
extends Area3D


@export var radius: float = 5.0:
	set(new_value):
		radius = new_value
		construct_collision_shape()

var collision_shape: CollisionShape3D


func construct_collision_shape() -> void:
	# Add Sphere collision shape
	if !collision_shape:
		collision_shape = CollisionShape3D.new()
		collision_shape.shape = SphereShape3D.new()
		add_child(collision_shape)
		collision_shape.owner = get_tree().edited_scene_root
		print("collision shape added")
		
	collision_shape.shape.radius = radius

Are there any errors?

Yes, I restarted and noticed this error:

ERROR: res://addons/gravity_systems/gravity_volumes/gravity_volume_planet.gd:78 - Invalid access to property or key ‘edited_scene_root’ on a base object of type ‘null instance’.

Line 78 is this one:

		collision_shape.owner = get_tree().edited_scene_root

I think that the GravityVolume node might not be inside the scene tree when that code is run. The example in the docs puts the code to assign the owner into the _ready function to avoid that issue.

1 Like

Ok, thank you, I now have split my construction function in two (construct and update) and now only construct the collision from the _ready function, and I added checks everywhere to make sure the collision_shape exists:

@tool
class_name PlanetGravityVolume
extends GravityVolume

## Radius at which the gravity will be maximum if gradient is enabled.
@export var radius: float = 5.0:
	set(new_value):
		radius = new_value
		#construct_collision_shape()
		update_collision_shape()

var collision_shape: CollisionShape3D = null


func _ready() -> void:
	construct_collision_shape()

func construct_collision_shape() -> void:
	# Add Sphere collision shape
	if !collision_shape:
		collision_shape = CollisionShape3D.new()
		#var shape := SphereShape3D.new()
		collision_shape.shape = SphereShape3D.new()
		add_child(collision_shape)
		collision_shape.owner = get_tree().edited_scene_root
		print("collision shape added")
		update_collision_shape()
		
func update_collision_shape() -> void:
	if collision_shape:
		collision_shape.shape.radius = radius

But the new issue I discovered, is that a new CollisionShape3D is added each time I reload the scene, as collision_shape assigned value isn’t persistent.

So, I guess my next step is to update my construct_collision_shape() function to check for an existing child:

func construct_collision_shape() -> void:
	# Add Sphere collision shape
	var child_node = find_child("*")
	if child_node and child_node.get_class() == "CollisionShape3D":
		collision_shape = child_node
	else:
		if !collision_shape:
			collision_shape = CollisionShape3D.new()
			#var shape := SphereShape3D.new()
			collision_shape.shape = SphereShape3D.new()
			add_child(collision_shape)
			collision_shape.owner = get_tree().edited_scene_root
			print("collision shape added")
			update_collision_shape()

It seems to work, but I feel I made something a bit complex for such a simple function.

1 Like

I started pointing out things and ended up having to rewrite a fair amount to get it working with no errors. It now also deals with accidentally deleting the SphereShape3D resource or the CollisionShape3D node. (Although you’ll have to reload the scene or change the radius to recreate them.)

This version does not create multiple CollisionShape3D nodes when you reload the level, and deals with all the edge cases I could think of. I added comments on the changes where I thought it would be helpful.

@tool
class_name GravityVolume extends Area3D


## Radius at which the gravity will be maximum if gradient is enabled.
@export var radius: float = 5.0:
	set(new_value):
		if not is_node_ready(): # When entering the scene (i.e. being loaded, this property is run - we don't want that)
			await ready
		radius = new_value
		if collision_shape_3d and collision_shape_3d.shape: #Second check in case you accidentally erase the shape in the editor and don't notice
			collision_shape_3d.shape.radius = radius
		else:
			construct_collision_shape() #Just in case you accidentally deleted this node in the editor by mistake.

var collision_shape_3d: CollisionShape3D #Made the variable name more descriptive to reduce cognitive load (code readability)


func _ready() -> void:
	ready.connect(_on_ready)


# This needs to be run after the node is fully loaded in the tree, otherwise collision_shape_3d is null even though it will exist in milliseconds
func _on_ready() -> void:
	if not collision_shape_3d:
		construct_collision_shape()


func construct_collision_shape() -> void:
	# Add Sphere collision shape
	# Detects if any CollisionShape3D node exists, which means adding other
	# child nodes to this in the editor won't cause problems.
	for node in get_children():
		if node is CollisionShape3D:
			collision_shape_3d = node
			if not collision_shape_3d.shape: #In case the collision shape got deleted
				collision_shape_3d.shape = SphereShape3D.new()
				collision_shape_3d.shape.radius = radius
			break
	if not collision_shape_3d:
		collision_shape_3d = CollisionShape3D.new()
		collision_shape_3d.name = "CollisionShape3D" #Added name to pretty up the scene tree
		collision_shape_3d.shape = SphereShape3D.new()
		collision_shape_3d.shape.radius = radius
		collision_shape_3d.tree_exited.connect(_on_collision_shape_deleted)
		add_child(collision_shape_3d)
		if Engine.is_editor_hint():
			collision_shape_3d.owner = get_tree().edited_scene_root


func _on_collision_shape_deleted() -> void:
	collision_shape_3d.tree_exited.disconnect(_on_collision_shape_deleted)
	collision_shape_3d = null

#Deleted update_collision_shape() because the check is only needed one place and otherwise it is one line of code
2 Likes

Great, thank a lot!
I’m new to Godot Engine, I learned a lot reading your notes and checking how you rewrote this. That’s really helpful!

For those reading this later, another useful line I added after constructing the collision shape to make the collision shape unique:
collision_shape_3d.shape.resource_local_to_scene = true

Without it, all instances would share the same shape.

1 Like