OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / selection_outline.gd

164 lines · gd ref:
extends Node3D
class_name SelectionOutline
## THICK SELECTION OUTLINES (docs/editor-texturing-selection-move.md §3).
##
## The selection used to be a cyan wash over every face of the brush, double-sided and
## drawn through walls — ~58 % cyan over the texture, so "I can't see what I am working
## on" (user, 2026-10-03). Blender, TrenchBroom and Godot all outline instead: strong
## depth-tested edges plus a dim copy where they are hidden.
##
## Godot draws PRIMITIVE_LINES at 1 px, so each segment is a QUAD that the shader widens
## to `width_px` on screen (CUSTOM0 = the other endpoint + which side). Each layer is two
## meshes: VISIBLE (depth-tested, a hair toward the camera so it wins against its own
## face) and HIDDEN (no depth test, faint, thinner) for edges behind geometry.

const _SHADER := """
shader_type spatial;
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_never%s;
uniform vec4 color : source_color = vec4(1.0, 0.34, 0.0, 1.0);
uniform float width_px = 2.0;
void vertex() {
	vec4 a = PROJECTION_MATRIX * MODELVIEW_MATRIX * vec4(VERTEX, 1.0);
	vec4 b = PROJECTION_MATRIX * MODELVIEW_MATRIX * vec4(CUSTOM0.xyz, 1.0);
	if (a.w <= 0.0 || b.w <= 0.0) {
		POSITION = vec4(2.0, 2.0, 2.0, 1.0);   // an endpoint behind the eye: drop the quad
	} else {
		vec2 d = (b.xy / b.w - a.xy / a.w) * VIEWPORT_SIZE;
		vec2 dir = length(d) > 1e-6 ? normalize(d) : vec2(1.0, 0.0);
		vec2 side = vec2(-dir.y, dir.x);
		// NDC spans 2 per viewport: k px = 2k / size; half the width each side.
		a.xy += side * CUSTOM0.w * width_px / VIEWPORT_SIZE * a.w;
		a.z += 0.00005 * a.w;                   // reverse-Z: a hair toward the camera
		POSITION = a;
	}
}
void fragment() {
	ALBEDO = color.rgb;
	ALPHA = color.a;
}
"""

static var _shader_vis: Shader = null
static var _shader_hid: Shader = null

var _layers := {}     # name -> [visible MeshInstance3D, hidden MeshInstance3D or null]


static func _shader(hidden: bool) -> Shader:
	if hidden:
		if _shader_hid == null:
			_shader_hid = Shader.new()
			_shader_hid.code = _SHADER % ", depth_test_disabled"
		return _shader_hid
	if _shader_vis == null:
		_shader_vis = Shader.new()
		_shader_vis.code = _SHADER % ""
	return _shader_vis


## Replace one layer's lines. `segments` are endpoint pairs (a0, b0, a1, b1, ...).
## hidden_alpha 0 = no hidden copy (hover is never drawn through walls).
func set_layer(name: String, segments: PackedVector3Array, color: Color, width_px: float,
		hidden_alpha: float = 0.3, hidden_width_px: float = 1.0) -> void:
	segments = _unique(segments)
	var pair: Array = _layers.get(name, [null, null])
	pair[0] = _apply(pair[0], name + "_vis", segments, color, width_px, false)
	if hidden_alpha > 0.0:
		pair[1] = _apply(pair[1], name + "_hid", segments,
			Color(color.r, color.g, color.b, color.a * hidden_alpha), hidden_width_px, true)
	elif pair[1] != null and is_instance_valid(pair[1]):
		(pair[1] as MeshInstance3D).mesh = null
	_layers[name] = pair


## Each undirected edge once. DCLevel.get_brush_edges_of lists an edge per adjacent
## face, so a box arrives as 24 segments; drawn twice, the faint hidden copy doubles.
static func _unique(seg: PackedVector3Array) -> PackedVector3Array:
	var seen := {}
	var out := PackedVector3Array()
	for i in range(0, seg.size() - 1, 2):
		var a: Vector3 = seg[i]
		var b: Vector3 = seg[i + 1]
		var ka := a.snapped(Vector3.ONE * 1e-4)
		var kb := b.snapped(Vector3.ONE * 1e-4)
		var key := [ka, kb] if str(ka) < str(kb) else [kb, ka]
		if seen.has(key):
			continue
		seen[key] = true
		out.append(a)
		out.append(b)
	return out


func clear_layer(name: String) -> void:
	set_layer(name, PackedVector3Array(), Color.WHITE, 1.0, 0.0)


## For gates: {segments, color, width, hidden_alpha} of a layer.
func layer_info(name: String) -> Dictionary:
	if not _layers.has(name):
		return {}
	var vis: MeshInstance3D = _layers[name][0]
	var hid: MeshInstance3D = _layers[name][1]
	var n := 0
	if vis != null and vis.mesh != null and vis.mesh.get_surface_count() > 0:
		n = (vis.mesh.surface_get_arrays(0)[Mesh.ARRAY_VERTEX] as PackedVector3Array).size() / 4
	var mat: ShaderMaterial = vis.material_override if vis != null else null
	var out := {"segments": n}
	if mat != null:
		out["color"] = mat.get_shader_parameter("color")
		out["width"] = mat.get_shader_parameter("width_px")
	if hid != null and hid.mesh != null and hid.material_override != null:
		out["hidden_alpha"] = ((hid.material_override as ShaderMaterial).get_shader_parameter("color") as Color).a
	else:
		out["hidden_alpha"] = 0.0
	return out


func _apply(mi: MeshInstance3D, nm: String, seg: PackedVector3Array, color: Color,
		width_px: float, hidden: bool) -> MeshInstance3D:
	if mi == null or not is_instance_valid(mi):
		mi = MeshInstance3D.new()
		mi.name = nm
		mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		var m := ShaderMaterial.new()
		m.shader = _shader(hidden)
		# Hidden copy draws after the visible one, so its faint colour never dims a
		# visible edge it overlaps.
		m.render_priority = 1 if hidden else 2
		mi.material_override = m
		add_child(mi)
	var mat: ShaderMaterial = mi.material_override
	mat.set_shader_parameter("color", color)
	mat.set_shader_parameter("width_px", width_px)
	if seg.size() < 2:
		mi.mesh = null
		return mi
	var verts := PackedVector3Array()
	var custom := PackedFloat32Array()
	var idx := PackedInt32Array()
	for i in range(0, seg.size() - 1, 2):
		var a: Vector3 = seg[i]
		var b: Vector3 = seg[i + 1]
		var base := verts.size()
		# Four corners: (a, side -1) (a, +1) (b, +1) (b, -1); each knows the OTHER end.
		for c: Array in [[a, b, -1.0], [a, b, 1.0], [b, a, -1.0], [b, a, 1.0]]:
			verts.append(c[0])
			var o: Vector3 = c[1]
			# The side is mirrored at the b end because its direction is reversed.
			var s: float = c[2] if (c[0] as Vector3) == a else -float(c[2])
			custom.append_array(PackedFloat32Array([o.x, o.y, o.z, s]))
		idx.append_array(PackedInt32Array([base, base + 1, base + 3, base, base + 3, base + 2]))
	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = verts
	arrays[Mesh.ARRAY_CUSTOM0] = custom
	arrays[Mesh.ARRAY_INDEX] = idx
	var am := ArrayMesh.new()
	am.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays, [], {},
		Mesh.ARRAY_CUSTOM_RGBA_FLOAT << Mesh.ARRAY_FORMAT_CUSTOM0_SHIFT)
	mi.mesh = am
	# Corners move in clip space, never far from the segment; a margin keeps them unculled.
	mi.extra_cull_margin = 1.0
	return mi