OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / brush_view.gd

220 lines · gd ref:
extends Node3D
class_name BrushView
## THE WIREFRAME — see the structure you are editing.
##
## The level renders as one merged surface, so which brush is which, where one ends
## and the next begins, and whether the thing under the crosshair is one wall or
## three were all invisible. That made the clip tool read as broken: a split produces
## two brushes that tile the original EXACTLY, so the rendered mesh is byte-identical
## and a correct cut looks like nothing happened. "C does nothing, Delete works" was
## a reporting failure, not a geometry one.
##
## Two meshes, because they answer different questions and want different depth
## behaviour:
##
##   ALL — every live brush, DEPTH TESTED, so the outlines sit on the surfaces they
##         belong to and read as structure rather than as an x-ray.
##   AIMED — the brush under the crosshair, drawn ON TOP, because "what am I about to
##         cut" must be answerable even when the brush is behind something.
##
## REBUILT ON DEMAND, NOT PER FRAME. START.MAP is ~1183 brushes and ~7000 faces, so
## the full wireframe is tens of thousands of segments — fine to draw, far too much to
## regenerate every frame. The full mesh is rebuilt when the level's geometry changes
## (the caller says so after a rebuild); the aimed outline is rebuilt only when the
## aimed brush changes, and it is one brush.

# NEUTRAL GREY, so the structure never competes with the orange selection outlines
# (it was the same cyan as the old selection wash). Hover is a pale yellow.
const COLOR_ALL := Color(0.7, 0.7, 0.7, 0.18)
## DIMMED 2026-10-05 ("make the auto highlight less bright"): 0.75 -> 0.4. It follows
## the crosshair over everything you look at, on top of every wall, so at 0.75 it
## out-shouted the orange SELECTION outlines that mark what you actually chose.
const COLOR_AIMED := Color(1.0, 0.95, 0.63, 0.4)
## Handles are drawn for the AIMED brush only. Every corner in the level at once is
## a starfield, not an affordance — you cannot aim at a handle you cannot pick out.
## Dimmed with the outline (0.95 -> 0.55): still findable, no longer the brightest
## thing on screen.
const COLOR_HANDLE := Color(1.0, 0.9, 0.4, 0.55)
const HANDLE_SIZE := 7.0
## CLEARANCE volumes — air the level owns, so the terrain is cut out of it
## (hyper-games #25 §2). Magenta and ON TOP, because both differences are the
## point: it is not a wall, and it is not visible from any other evidence. The
## only sign a passage exists is the hole in the hillside around it, which is
## exactly the thing you cannot see while standing in the passage.
const COLOR_CLEARANCE := Color(1.0, 0.35, 0.85, 0.5)
## LIVE CUTTERS (docs/cutting-and-operations.md §9a): the shapes a live cut takes its
## planes from. They render nothing — a solid cutter would fill the hole it cuts — so
## this wire is their only appearance. Cyan and ON TOP: a cutter sits inside the wall
## it cuts, and is the thing you aim at to move the hole.
const COLOR_CUTTER := Color(0.3, 0.9, 1.0, 0.6)

var level: Node = null
## Handles are the VERTEX affordance: dots you aim at to pick a corner. Drawn in edge
## or face mode they sit on top of the thing you actually selected and read as the
## selection — an edge highlight "showing up as two vertices" is exactly that.
var show_handles: bool = true:
	set(v):
		show_handles = v
		if not v and _handles != null and is_instance_valid(_handles):
			(_handles.mesh as ArrayMesh).clear_surfaces()
		elif v and _aimed_id > 0:
			var was := _aimed_id
			_aimed_id = -1          # force the next set_aimed to rebuild
			set_aimed(was)

## OFF BY DEFAULT. A clearance volume encloses the shape it serves, so drawing
## them always would put a magenta box over every staircase in the level.
var show_clearance: bool = false:
	set(v):
		show_clearance = v
		_refresh_clearance()

## THE LEVEL'S OUTLINES (the ALL layer) — the Overlays "Brush outlines" row / V (keymap
## step 6.5). Only that layer: the node's own `visible` is BUILD MODE, and the aimed
## outline, its handles, clearance and cutters each answer to their own switch. (V used
## to hide the whole node, so outlines off took clearance and cutters with it.)
var show_outlines: bool = true:
	set(v):
		show_outlines = v
		if _all != null and is_instance_valid(_all):
			_all.visible = v
			if v and visible:
				refresh()

## ON BY DEFAULT: a cutter you just made must not vanish. The Overlays "Live cutters" row.
var show_cutters: bool = true:
	set(v):
		show_cutters = v
		_refresh_cutters()

var _all: MeshInstance3D = null
var _cutters: MeshInstance3D = null
var _aimed: MeshInstance3D = null
var _handles: MeshInstance3D = null
var _clearance: MeshInstance3D = null
var _aimed_id: int = -1


func _ready() -> void:
	_all = _make_layer(COLOR_ALL, false)
	_aimed = _make_layer(COLOR_AIMED, true)
	_handles = _make_layer(COLOR_HANDLE, true, true)
	_clearance = _make_layer(COLOR_CLEARANCE, true)
	_cutters = _make_layer(COLOR_CUTTER, true)
	_all.visible = show_outlines
	_refresh_clearance()
	_refresh_cutters()


## `on_top` draws without depth testing — right for the single aimed brush, wrong for
## the whole level, which would turn into an unreadable haystack of every edge in the
## map showing through every wall.
func _make_layer(color: Color, on_top: bool, points := false) -> MeshInstance3D:
	var mi := MeshInstance3D.new()
	mi.mesh = ArrayMesh.new()
	var m := StandardMaterial3D.new()
	m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	m.albedo_color = color
	m.vertex_color_use_as_albedo = false
	m.no_depth_test = on_top
	# Points need an explicit size or they render as single pixels — invisible at any
	# distance, which defeats the purpose of a handle.
	if points:
		m.use_point_size = true
		m.point_size = HANDLE_SIZE
	# Lines are coplanar with the faces they outline, so without a nudge toward the
	# camera they z-fight along their entire length.
	if not on_top:
		m.render_priority = 1
	mi.material_override = m
	mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	add_child(mi)
	return mi


## Regenerate the whole-level wireframe. Call after the level's geometry changes —
## an import, a rebuild, a fold — and not otherwise.
func refresh() -> void:
	if level == null or not is_instance_valid(level) or not level.has_method("get_brush_edges"):
		return
	# Tens of thousands of segments on a big level: not regenerated while hidden
	# (show_outlines refreshes when it comes back on).
	if show_outlines:
		_fill(_all, level.get_brush_edges())
	_refresh_clearance()
	_refresh_cutters()


## The clearance layer follows both the toggle and the geometry, so it is rebuilt
## from refresh() as well as from the setter — a passage created while the view is
## on must appear without needing the key pressed twice.
func _refresh_clearance() -> void:
	if _clearance == null or not is_instance_valid(_clearance):
		return
	if not show_clearance or level == null or not is_instance_valid(level) \
			or not level.has_method("get_clearance_edges"):
		(_clearance.mesh as ArrayMesh).clear_surfaces()
		return
	_fill(_clearance, level.get_clearance_edges())


## The cutter layer: like clearance, rebuilt from refresh() and from its setter.
## Skipped on a DCLevel build without cutters.
func _refresh_cutters() -> void:
	if _cutters == null or not is_instance_valid(_cutters):
		return
	if not show_cutters or level == null or not is_instance_valid(level) \
			or not level.has_method("get_cutter_edges"):
		(_cutters.mesh as ArrayMesh).clear_surfaces()
		return
	_fill(_cutters, level.get_cutter_edges())


## Outline one brush, or none. Cheap: called as the crosshair moves, but it rebuilds
## a handful of segments and only when the target actually changed.
func set_aimed(brush_id: int) -> void:
	if brush_id == _aimed_id:
		return
	_aimed_id = brush_id
	if level == null or not is_instance_valid(level) or brush_id <= 0:
		(_aimed.mesh as ArrayMesh).clear_surfaces()
		(_handles.mesh as ArrayMesh).clear_surfaces()
		return
	_fill(_aimed, level.get_brush_edges_of(brush_id))
	if show_handles:
		_fill(_handles, level.get_brush_vertices_of(brush_id), Mesh.PRIMITIVE_POINTS)
	else:
		(_handles.mesh as ArrayMesh).clear_surfaces()


func _fill(mi: MeshInstance3D, pts: PackedVector3Array,
		   primitive := Mesh.PRIMITIVE_LINES) -> void:
	var am: ArrayMesh = mi.mesh
	am.clear_surfaces()
	if pts.is_empty():
		return
	# The store hands back LINE PAIRS already, so this is a straight copy — no
	# winding, no triangulation, no per-face work in GDScript.
	#
	# ONE ARRAY UPLOAD, not a vertex per call (2026-10-05): this was an ImmediateMesh
	# fed by `for p in pts: surface_add_vertex(p)` — ~48k GDScript calls at 2000 boxes,
	# 21 ms on every brush fold (gate_level_collision_scale).
	var arrays := []
	arrays.resize(Mesh.ARRAY_MAX)
	arrays[Mesh.ARRAY_VERTEX] = pts
	am.add_surface_from_arrays(primitive, arrays)


func set_shown(shown: bool) -> void:
	visible = shown
	if shown:
		refresh()


func segment_count() -> int:
	var im: ArrayMesh = _all.mesh
	if im.get_surface_count() == 0:
		return 0
	return im.surface_get_arrays(0)[Mesh.ARRAY_VERTEX].size() / 2