extends Node3D
## CSG test with draggable Godot primitives as brush handles.
## Move the MeshInstance3D children in the viewport — terrain follows.
## Per-LOD grid resolution: LOD0=64, LOD1-2=32, LOD3+=16.
## NOTE: @tool removed — brush visualizations were leaking into other scenes
## via edited_scene_root owner assignment. Run the scene (F5) to test.
@onready var terrain: Node3D = $DCTerrain
@onready var stats_label: Label = $CanvasLayer/StatsLabel
var _terrain_ready: bool = false
var _wait_frames: int = 0
var _prev_leaf_count: int = -1
var _paused: bool = false
## CSG primitive type constants (match C++ enum)
const CSG_SPHERE: int = 0
const CSG_BOX: int = 1
## CSG operation constants (match C++ enum)
const CSG_UNION: int = 0
const CSG_SUBTRACT: int = 1
const CSG_INTERSECT: int = 2
## Brush tracking: each entry maps a MeshInstance3D handle to a CSG primitive index
var _brushes: Array[Dictionary] = []
# Each dict: { "node": MeshInstance3D, "csg_index": int, "last_pos": Vector3, "last_size": Vector3 }
## Spacing between CSG demos
const DEMO_SPACING: float = 40.0
func _create_brush_material(color: Color, op_type: int) -> StandardMaterial3D:
var mat: StandardMaterial3D = StandardMaterial3D.new()
mat.albedo_color = color
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color.a = 0.3
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
# Subtract brushes get a different wireframe look
if op_type == CSG_SUBTRACT:
mat.albedo_color.a = 0.15
return mat
func _create_box_brush(pos: Vector3, half_extents: Vector3, op: int, group: int, color: Color) -> Dictionary:
var mesh_inst: MeshInstance3D = MeshInstance3D.new()
mesh_inst.name = "CSGBox_g%d" % group
var box: BoxMesh = BoxMesh.new()
box.size = half_extents * 2.0 # BoxMesh.size is full extent
mesh_inst.mesh = box
mesh_inst.material_override = _create_brush_material(color, op)
mesh_inst.position = pos
add_child(mesh_inst)
var csg_idx: int = terrain.get_csg_primitive_count()
terrain.add_csg_primitive(CSG_BOX, op, group, pos, half_extents)
return {
"node": mesh_inst,
"csg_index": csg_idx,
"csg_type": CSG_BOX,
"last_pos": pos,
"last_size": half_extents,
}
func _create_sphere_brush(pos: Vector3, radius: float, op: int, group: int, color: Color) -> Dictionary:
var mesh_inst: MeshInstance3D = MeshInstance3D.new()
mesh_inst.name = "CSGSphere_g%d" % group
var sphere: SphereMesh = SphereMesh.new()
sphere.radius = radius
sphere.height = radius * 2.0
mesh_inst.mesh = sphere
mesh_inst.material_override = _create_brush_material(color, op)
mesh_inst.position = pos
add_child(mesh_inst)
var csg_idx: int = terrain.get_csg_primitive_count()
terrain.add_csg_primitive(CSG_SPHERE, op, group, pos, Vector3(radius, 0.0, 0.0))
return {
"node": mesh_inst,
"csg_index": csg_idx,
"csg_type": CSG_SPHERE,
"last_pos": pos,
"last_size": Vector3(radius, 0.0, 0.0),
}
func _ready() -> void:
if not terrain:
return
if not Engine.is_editor_hint():
process_mode = Node.PROCESS_MODE_ALWAYS
# Remove any previously created brush children (editor re-entry)
for child in get_children():
if child is MeshInstance3D and child.name.begins_with("CSG"):
child.queue_free()
# Flat ground plane only — no noise
terrain.use_noise = false
terrain.voxel_size = 0.5
terrain.octree_depth = 6
terrain.ground_bias = 0.3
terrain.terrain_color = Color(0.5, 0.5, 0.5)
terrain.csg_refinement = 4 # 4× finer voxels near CSG (octree prunes empty cells)
# Clear any existing CSG primitives
terrain.clear_csg_primitives()
_brushes.clear()
# --- Colors per operation ---
var union_color: Color = Color(0.2, 0.6, 1.0) # blue = additive
var intersect_color: Color = Color(0.2, 1.0, 0.3) # green = intersection
var subtract_color: Color = Color(1.0, 0.3, 0.2) # red = subtractive
# --- CSG demos ---
var cube_half: Vector3 = Vector3(5.0, 5.0, 5.0)
var sphere_r: float = 10.0
var y_center: float = 0.0
var z_offset: float = 5.0
# Group 0: UNION
var x0: float = -DEMO_SPACING
_brushes.append(_create_box_brush(
Vector3(x0, y_center, 0.0), cube_half, CSG_UNION, 0, union_color))
_brushes.append(_create_sphere_brush(
Vector3(x0, y_center, z_offset), sphere_r, CSG_UNION, 0, union_color))
# Group 1: INTERSECT
var x1: float = 0.0
_brushes.append(_create_box_brush(
Vector3(x1, y_center, 0.0), cube_half, CSG_UNION, 1, intersect_color))
_brushes.append(_create_sphere_brush(
Vector3(x1, y_center, z_offset), sphere_r, CSG_INTERSECT, 1, intersect_color))
# Group 2: SUBTRACT
var x2: float = DEMO_SPACING
_brushes.append(_create_box_brush(
Vector3(x2, y_center, 0.0), cube_half, CSG_UNION, 2, subtract_color))
_brushes.append(_create_sphere_brush(
Vector3(x2, y_center, z_offset), sphere_r, CSG_SUBTRACT, 2, subtract_color))
terrain.rebuild()
# Runtime-only: set up player
if not Engine.is_editor_hint():
var player: CharacterBody3D = $Player
player.global_position = Vector3(20.0, 20.0, -50.0)
player.set_physics_process(false)
func _process(delta: float) -> void:
# --- Sync brush positions to CSG primitives (works in editor AND runtime) ---
for brush in _brushes:
var node: MeshInstance3D = brush["node"] as MeshInstance3D
if not is_instance_valid(node):
continue
var current_pos: Vector3 = node.position
var last_pos: Vector3 = brush["last_pos"]
# Check if position changed (dragged in viewport)
if not current_pos.is_equal_approx(last_pos):
var idx: int = brush["csg_index"]
terrain.set_csg_primitive_position(idx, current_pos)
brush["last_pos"] = current_pos
# Check if scale changed (box resize via scale gizmo)
if brush["csg_type"] == CSG_BOX:
var box_mesh: BoxMesh = node.mesh as BoxMesh
if box_mesh:
var new_half: Vector3 = box_mesh.size * 0.5 * node.scale
var old_half: Vector3 = brush["last_size"]
if not new_half.is_equal_approx(old_half):
var idx: int = brush["csg_index"]
terrain.set_csg_primitive_size(idx, new_half)
brush["last_size"] = new_half
elif brush["csg_type"] == CSG_SPHERE:
var sphere_mesh: SphereMesh = node.mesh as SphereMesh
if sphere_mesh:
var new_r: float = sphere_mesh.radius * node.scale.x
var old_r: float = brush["last_size"].x
if absf(new_r - old_r) > 0.01:
var idx: int = brush["csg_index"]
terrain.set_csg_primitive_size(idx, Vector3(new_r, 0.0, 0.0))
brush["last_size"] = Vector3(new_r, 0.0, 0.0)
if Engine.is_editor_hint():
return
# --- Runtime-only below ---
var player: CharacterBody3D = $Player
if not _terrain_ready:
var active: int = terrain.get_active_chunk_count()
var collision_q: int = terrain.get_collision_queue_size()
# Wait for enough chunks with collision to be ready.
# Don't require collision_q==0 — seam generation keeps it non-zero.
if active >= 4 and collision_q == 0:
_wait_frames += 1
if _wait_frames >= 3:
_terrain_ready = true
player.set_physics_process(true)
print("CSG test terrain ready (active: %d)" % active)
elif active >= 10:
# Fallback: if we have enough chunks, enable player even with pending collision
_terrain_ready = true
player.set_physics_process(true)
print("CSG test terrain ready (fallback, active: %d, collision_q: %d)" % [active, collision_q])
if stats_label and terrain and player:
var chunks: int = terrain.get_chunk_count()
var active: int = terrain.get_active_chunk_count()
var pending: int = terrain.get_pending_count()
var fps: int = Engine.get_frames_per_second()
var pos: Vector3 = player.global_position
var pause_str: String = " [PAUSED]" if _paused else ""
var csg_count: int = terrain.get_csg_primitive_count()
var refine: int = terrain.csg_refinement if terrain.has_method("get_csg_refinement") else 1
stats_label.text = "FPS: %d%s\nChunks: %d (active: %d, pending: %d)\nCSG: %d primitives (refine: %dx)\nPos: (%.0f, %.0f, %.0f)\n[P] pause [R] rebuild\n\nDrag CSG brushes in viewport to move them\n Left(blue): UNION Center(green): INTERSECT Right(red): SUBTRACT" % [
fps, pause_str, chunks, active, pending, csg_count, refine, pos.x, pos.y, pos.z]
func _unhandled_input(event: InputEvent) -> void:
if Engine.is_editor_hint():
return
if event is InputEventKey and event.pressed and event.keycode == KEY_P:
_paused = !_paused
get_tree().paused = _paused
if event is InputEventKey and event.pressed and event.keycode == KEY_R:
terrain.rebuild()
print("Terrain rebuild triggered")
if event is InputEventKey and event.pressed and event.keycode == KEY_F12:
var img: Image = get_viewport().get_texture().get_image()
var ts: String = Time.get_datetime_string_from_system().replace(":", "-")
var path: String = "/home/fleeky/Desktop/hyper-tps/csg_%s.png" % ts
img.save_png(path)
print("Screenshot saved: %s" % path)