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