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