OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / dc_csg_test.gd

245 lines · gd ref:
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)