extends "res://scenes/maps/map_core.gd"
## DC Level test world — the EXACT brush level (DCLevel), NOT the octree clipmap.
##
## DCLevel owns a brush store and derives everything from it:
##
## BrushStore --csg4--> visible skin --dc_level_mesh--> ArrayMesh
## \--unclipped hulls-------------------> convex colliders
##
## This is step 3 of level-mesh-implementation.md §12 — "a standalone Godot scene
## rendering START as exact mesh". The gate is that doc's own acceptance criteria:
##
## triangles 12,747 (§6.2, csg4-clipped skin; RAW hulls would be 13,718)
## faces 6,487
## surface area 32,101 m²
## colliders 1,164 (§10 — 1156 brushes contribute visible FACES but all
## 1164 need volume; build collision from the render
## mesh and you fall through eight of them)
## max normal dev 0.034° (sharp edges, as a number rather than a claim)
##
## Every one of those came from an offline oracle (tests/test_csg4_polycount.cpp,
## test_level_mesh.cpp). This scene is the first time they meet a renderer, so a
## divergence here is a bug in the ENGINE port and the oracle is the reference.
##
## STANDALONE ARENA: there is deliberately no DCTerrain in this scene. §5a's escape
## hatch — with no procedural world there is no level<->world seam to solve, which
## is the one genuinely undesigned piece of the architecture ("outside walls" needs
## a real definition, and it is the crux).
##
## EPHEMERAL import (§9a.1): brushes are re-derived from the .MAP on every load and
## never written to StateBase. The durable path — editor "Load .MAP" writing brushes
## into room state — is a later step and is blocked on `world:level:` being added to
## BOTH MAP_SCOPED_PREFIXES and AUTHOR_SCOPED_PREFIXES in game_state.gd.
##
## KNOWN GAP (§10): DCLevel's colliders are raw PhysicsServer3D shapes on one static
## body with no body_attach_object_instance_id, so a raycast hit has no owner and the
## editor cannot resolve a brush_id from a pick. Must be fixed before the editor.
## Dev-only absolute paths, matching map_dc_terrain.gd's KEY_M import. Bundle into
## res:// before this ships anywhere but a dev box.
const MAP_PATH := "/storage/projects/gamedev/quake/quakemash/START.MAP"
## Per-scene override so a second scene can reuse this whole script with a
## different .MAP (e.g. a generated level). Empty = use MAP_PATH.
@export var map_path_override: String = ""
## Keep the .MAP's own light entities instead of replacing them with
## RTLIGHTS_PATH. Required for any map that is not START.
@export var use_map_lights: bool = false
## Colour every face by its MATERIAL ID alone -- no textures, no atlas. Emit a .MAP
## whose faces carry one material name per room and each room reads as one colour,
## which is how a decomposition gets judged in-engine rather than from a chart.
@export var flat_room_colors: bool = false
## Import a .MAP at all. OFF gives an EMPTY DCLevel with the whole editor, the
## journal and the admin surface still live, so brushes are built from scratch
## and persist like imported ones -- what map_mountain wants: brushes and terrain
## CSG on a world that has no Quake level. OFF also skips everything that only
## exists to present START.MAP: the .MAP fetch/serve over the mesh, WAD/PBR
## texturing, sky lights, rtlights, entities and the forced daylight.
## Default ON: every map that inherits this behaves exactly as before.
@export var level_import: bool = true
## The .MAP this scene should load. Everything that resolved MAP_PATH directly
## goes through here so the override reaches the overlay announce/request paths
## too — otherwise a peer would be served START.MAP for a generated level.
func _map_path() -> String:
return map_path_override if map_path_override != "" else MAP_PATH
const WAD_PATH := "/storage/projects/gamedev/quake/quakemash/gfx/base.wad"
const PAL_PATH := "/storage/projects/gamedev/quake/darkyplafe/id1/gfx/palette.lmp"
## hemebond/quake-textures build output — `<name>_gloss.jpg` / `_glow.jpg` / `_bump.jpg`
## per WAD texture. Same directory map_dc_terrain.gd:359 already loads its PBR from.
const TEX_BUILD_DIR := "/storage/projects/gamedev/quake/quake-textures/build_out"
## Hand-curated DarkPlaces realtime lights. These REPLACE the raw .MAP light
## entities: the importer's light* spawner is a crude fallback (range =
## brightness x world_scale, energy = clamp(brightness/200), shadows always off),
## whereas RtlightManager was canonicalized against DarkPlaces r_shadow.c —
## authored radius, per-light energy = ambient+diffuse+specular, shadow flags, and
## EMA importance selection. Without this the level is lit by the guess.
const RTLIGHTS_PATH := "/storage/projects/gamedev/quake/quake-rtlights/id1/maps/start.rtlights"
## 0.028 m/unit: Quake's 56-unit player becomes 1.58 m, matching our capsule. Same
## value map_dc_terrain uses, so geometry lands at identical scale and the two are
## directly comparable.
const WORLD_SCALE := 0.028
## Daylight. The inherited game.gd Sky3DExtended sits at 19.6 (night, sun energy 0) —
## fine for map_test, useless here: this world exists to judge sharp edges, crease
## geometry and material seams, and none of that is visible in the dark.
const DAY_TIME := 12.0
## Sky AreaLight3D tuning. INSET pushes the light off the brush face into the room
## so the solid brush it belongs to cannot occlude it. ENERGY is explicit because
## area_normalize_energy is off (see _spawn_sky_area_lights).
const SKY_LIGHT_INSET := 0.05
const SKY_LIGHT_ENERGY := 4.0
## How hard sky faces glow. The sky is a light SOURCE in the fiction — stained-glass
## behaviour — so the surface itself emits rather than merely being lit.
const SKY_EMISSION := 2.0
@onready var level: Node3D = get_node_or_null("DCLevel")
@onready var placeholder_floor: Node3D = get_node_or_null("PlaceholderFloor")
var _prev_time: float = -1.0
var _tod: Node = null
func _ready() -> void:
super._ready()
# THE BUILDER CONTEXT IS ACTIVE WHILE THIS MAP IS. Godot's InputMap has no notion
# of context, so without this the editor bindings would be live in every map —
# including ones where the same keys mean something else. Popped in _exit_tree.
# HELD AS self: on a map change the OLD map's pop runs after this push, and an
# unowned pop took `builder` away from the new map (gate_map_change_keys).
Keymap.push_context("builder", self)
# ON IN BUILD MODE, off otherwise: the wireframe is an editing aid, and drawing
# every brush edge in the level while you are playing would be noise.
GameMode.mode_changed.connect(func(m: String) -> void:
_ensure_brush_view().set_shown(m == GameMode.BUILD)
_ensure_entity_links()
_apply_overlays() # links follow the mode (and their overlay)
_show_highlights(m == GameMode.BUILD))
_apply_overlays() # the saved overlay states (picking flags)
# Noon exists to judge the Quake level's edges; a map with no level keeps its sky.
if level_import:
_force_daylight()
# The editor bars (redesign E1). Deferred: BuildDock must have built its regions.
call_deferred("_register_editor_bars")
if level == null:
push_warning("[MapDCLevel] No DCLevel node — the loaded libdc_terrain build does not register the class. Placeholder floor only.")
nav_rebuild.call_deferred() # bots still route over the scene's own colliders
return
# Defer: DCLevel::_ready creates its MeshInstance3D child, and _upload_collision
# needs get_world_3d(), so the node must be in the tree before we feed it.
call_deferred("_import_level")
# BOT NAVIGATION for every level map (see "navigation" below). Queued AFTER the
# import so the deferred calls run in that order and the brushes are in the store
# when the graph is built; later edits rebuild it on their own.
nav_rebuild.call_deferred()
func _exit_tree() -> void:
Keymap.pop_context("builder", self)
if _face_sel_on:
Keymap.pop_context("face_sel", self)
_face_sel_on = false
# The strip belongs to THIS map's pack material; the next map may have none.
if _tex_strip != null:
var dock := get_node_or_null("/root/BuildDock")
if dock != null:
dock.unregister("textures")
dock.unregister("face")
_tex_strip = null
_face_panel = null
# The asset browser drives THIS map's libraries (#120 L4).
if _asset_browser != null:
var dock3 := get_node_or_null("/root/BuildDock")
if dock3 != null:
dock3.unregister("assets")
_asset_browser = null
# The bars drive THIS map; the next map may not be a brush editor at all.
var dock2 := get_node_or_null("/root/BuildDock")
if dock2 != null:
dock2.unregister("topbar")
dock2.unregister("tools")
dock2.unregister("object")
dock2.unregister("bots")
_object_panel = null
_bot_panel = null
var hud := get_node_or_null("/root/BuildHud")
if hud != null:
hud.info_provider = Callable()
_top_bar = null
_tool_bar = null
# Leave the shared sky as we found it — game.gd's Sky3DExtended outlives this map
# and map_test expects its own time of day.
if _tod != null and _prev_time >= 0.0:
_tod.current_time = _prev_time
## Nudge the INHERITED sky rather than adding a WorldEnvironment. A second
## WorldEnvironment would trip game.gd's _remove_map_world_environment and detach
## game.gd's Sky3D entirely, making this map own sun/moon wiring and diverge from
## map_test. Borrowing the time of day is the smaller change.
func _force_daylight() -> void:
var game := get_tree().root.get_node_or_null("Game")
if game == null:
return
var sky := game.get_node_or_null("Sky3DExtended")
if sky == null:
return
_tod = sky.get_node_or_null("TimeOfDay")
if _tod == null or not ("current_time" in _tod):
return
# SAVED STATE WINS; THIS IS THE DEFAULT FOR A ROOM THAT HAS NONE.
#
# Without the check this fought the atmosphere panel and the winner was decided
# by arrival order rather than by intent: on a cold join the room's saved sky
# lands AFTER the map (room state is still loading at _ready), so saved won —
# but on a later map change within the same room the value is already in
# GameState's mirror, no state_changed is re-emitted, and this overwrote it. So
# the same setting behaved differently depending on how you got here.
#
# A room with a saved time is a world someone authored. Forcing noon over it
# makes the panel's time slider pointless on every map that inherits this one.
# Through SkyDefaults now, so the rule this comment describes has ONE
# implementation — map_dc_terrain had none at all and was clobbering saved
# state on every entry (hyper-games #42 P1).
var keys: Array[String] = []
if GameState:
keys = GameState.keys_with_prefix(SkyDefaults.PREFIX)
if SkyDefaults.unsaved(["current_time"] as Array[String], keys).is_empty():
print("[MapDCLevel] daylight SKIPPED — the room has a saved sky time (%.1f)"
% float(GameState.get_state("world:sky:current_time")))
return
_prev_time = _tod.current_time
_tod.current_time = DAY_TIME
# Sky3D bug (game.gd documents it): current_time's setter does not call
# _update_celestial_coords, and with game_time_enabled=false _process never
# does either — so the sun does not actually move without this. game.gd hooks
# time_changed for the same reason.
if _tod.has_method("_update_celestial_coords"):
_tod._update_celestial_coords()
print("[MapDCLevel] daylight — TimeOfDay %.1f -> %.1f" % [_prev_time, DAY_TIME])
## Resolve the .MAP, then import it. Two ways in, and the second is why the
## overlay exists: this scene's paths are dev-box absolutes (see the const block),
## so a peer that simply does not have START.MAP had no level at all and no way to
## get one. Now it asks the mesh for the hash another peer announced.
func _import_level() -> void:
# EAGERLY, not on first keypress. _ensure_persistence was previously reached
# only from the K carve keybind, so on a headless peer — which presses nothing —
# _persist stayed null and both branches below silently did nothing. It is also
# what subscribes to GameState.state_changed, so creating it lazily meant a peer
# did not replay ANOTHER peer's carves until it happened to carve something
# itself. Pre-existing, and this is the fix for both.
_ensure_persistence()
_bake_scene_boxes()
if not level_import:
_adopt_scene_objects() # before _start_empty_level's first fold
_start_empty_level()
return
if FileAccess.file_exists(_map_path()):
_import_from(_map_path())
_announce_source(_map_path())
return
_request_source()
## The level the map's SCENE BOXES are baked into. Base keys are "i:<level_id>:<n>" by
## insertion order, so they get a level of their own: a .MAP import (level 0) that grew
## a brush would otherwise shift every scene box's key and break edits aimed at them.
const SCENE_LEVEL_ID := 1
## SCENE BOXES BECOME BASE BRUSHES (2026-10-05: "the editor cannot select the created
## elements in the tscn starter map"). A StaticBody3D child whose collision is exactly
## one BoxShape3D was invisible to the editor — no journal identity, so a click logged
## "not level geometry" and nothing could select, move or delete it. Each becomes a
## BASE brush (key "", level SCENE_LEVEL_ID): the fold's rewind keeps it live, and
## brush.remove / translate / ... records target its key like any brush. Rotation and
## scale come from the shape's transform relative to the level.
##
## NODE-NAME ORDER, so the keys do not move when the .tscn is reordered (renaming a box
## does move them). Children that are not the box's collision or mesh (NorthHub's
## label) move up to the map, keeping their place. Before the first fold, which is
## what makes the keys identical on every peer. Returns how many were baked.
func _bake_scene_boxes() -> int:
if level == null:
return 0
var boxes: Array = []
for c in get_children():
if not (c is StaticBody3D):
continue
var shapes: Array = c.get_children().filter(func(n: Node) -> bool: return n is CollisionShape3D)
if shapes.size() != 1:
continue
var cs := shapes[0] as CollisionShape3D
if cs.disabled or not (cs.shape is BoxShape3D):
continue
boxes.append([String(c.name), c, cs])
boxes.sort_custom(func(a: Array, b: Array) -> bool: return String(a[0]) < String(b[0]))
var to_level: Transform3D = (level as Node3D).global_transform.affine_inverse()
var n := 0
for e: Array in boxes:
var body := e[1] as StaticBody3D
var cs := e[2] as CollisionShape3D
var t: Transform3D = to_level * cs.global_transform
var half: Vector3 = (cs.shape as BoxShape3D).size * 0.5
var planes := PackedFloat32Array()
for i in 3:
var col: Vector3 = t.basis[i]
var nrm := col.normalized()
var ext: float = half[i] * col.length()
var dc: float = nrm.dot(t.origin)
planes.append_array(PackedFloat32Array([-nrm.x, -nrm.y, -nrm.z, -dc + ext]))
planes.append_array(PackedFloat32Array([nrm.x, nrm.y, nrm.z, dc + ext]))
if int(level.add_brush(planes, 0, PackedInt32Array(), SCENE_LEVEL_ID)) <= 0:
push_warning("[MapDCLevel] scene box %s encloses nothing — left as a scene node" % e[0])
continue
for k in body.get_children():
if k == cs or k is CSGShape3D or k is MeshInstance3D:
continue
var g := (k as Node3D).global_transform if k is Node3D else Transform3D.IDENTITY
body.remove_child(k)
add_child(k)
k.set_meta("scene_content", true) # authored, so adoptable (_is_scene_content)
if k is Node3D:
(k as Node3D).global_transform = g
remove_child(body)
body.queue_free()
n += 1
if n > 0:
level.rebuild()
print("[MapDCLevel] baked %d scene box(es) into base brushes (level %d)" % [n, SCENE_LEVEL_ID])
return n
## EVERYTHING ELSE THE MAP AUTHORED IS AN OBJECT TOO (user 2026-10-05: "wallrunrange
## is undeletable ? everything should be able to be deleted"). Any other node the
## .tscn put directly under the map — the wall-run range, a label lifted off a baked
## box — is adopted as a generic base object: no props, but select / move / delete /
## undo through the journal like any other, its children going with it. NOT the level
## itself (DCLevel, DCTerrain): that is what the brushes live in, not a thing in it.
## Authored only (owner == the map, or marked by the box bake): by the time the import
## path adopts, the map also holds RUNTIME children (rt lights, entities, editor nodes)
## that are not content and are rebuilt every load.
const SCENE_NODE_TYPE := "scene.node"
func _is_scene_content(c: Node) -> bool:
if not (c is Node3D) or c == level or c.is_class("DCLevel") or c.is_class("DCTerrain"):
return false
return c.owner == self or c.has_meta("scene_content")
## TELEPORTERS AND SPAWN POINTS BECOME OBJECTS (2026-10-05). The map's own teleporter
## instances and spawn_points Marker3Ds are ADOPTED by SceneObjects as base objects,
## dot "scene:<Name>", so they can be selected, moved, edited and deleted through the
## journal like placed objects (scene_objects.gd add_base). Before the first fold.
func _adopt_scene_objects() -> int:
var objs := _ensure_objects()
if objs == null:
return 0
var tele_scene := String(PrefabCatalogue.PREFABS.get("teleporter", ""))
var n := 0
for c in get_children():
if not (c is Node3D) or c == objs:
continue
var type_id := ""
if c.scene_file_path == tele_scene or "target_map" in c:
type_id = "teleporter"
elif c is Marker3D and c.is_in_group("spawn_points"):
type_id = "spawn_point"
elif _is_scene_content(c):
type_id = SCENE_NODE_TYPE
if type_id == "":
continue
objs.add_base("scene:%s" % c.name, type_id, c as Node3D)
n += 1
if n > 0:
print("[MapDCLevel] adopted %d scene object(s) (teleporters, spawns, authored nodes)" % n)
return n
## level_import OFF: an EMPTY level that is otherwise the same editor.
##
## Everything _import_from does after the import that is not about START.MAP still
## has to happen, or the editor is half-wired: the brush wireframe, the outliner
## (registered after import there because it counts brushes) and a first fold, so a
## room that already holds brushes for this map replays them on entry. The journal
## needs no level source for that -- records() accepts an empty src on both sides.
##
## PACK-TEXTURED (#94 P1, design §8): dc_level.gdshader fed by the texture packs
## and DCLevel's derived projection table, kept current by LevelPackMaterial.sync()
## from _process. Faces nobody has textured draw the same grey as before.
##
## Falls back to a PLAIN material when the loaded dc_terrain build predates the
## texture API. Not vertex colour: DCLevel packs material_id into COLOR.r, which
## would paint every brush red.
func _start_empty_level() -> void:
var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
if mi != null:
_packs = LevelPackMaterial.new()
if _packs.install(level, mi, int(Settings.resolved_value("texture_res")),
int(Settings.resolved_value("surface_detail"))):
Settings.setting_changed.connect(_on_texture_setting)
_register_texture_strip()
else:
_packs = null
var mat := StandardMaterial3D.new()
mat.albedo_color = LevelPackMaterial.PLAIN
mat.roughness = 0.9
LevelPackMaterial.wear(level, mat) # LevelMesh AND LevelPockets
print("[MapDCLevel] dc_terrain build has no texture API — plain material")
_refresh_brush_view()
_register_outliner()
_register_asset_browser()
var st: Dictionary = _persist.refold() if _persist != null else {}
print("[MapDCLevel] empty level (level_import off) — %d brush record(s) replayed"
% int(st.get("applied", 0)))
## The empty level's pack material; null on the .MAP import path and on an old .so.
var _packs: LevelPackMaterial = null
## The player's CURRENT texture (#94 P1 §6): what every new brush face gets. Per
## player and not durable — it is a tool setting, like the grid size. "" = the pack
## default (the first Kenney grid). The texture browser / eyedropper (§9) set it.
var current_tex: String = ""
## Texture lock (#94 P1 D5, ON by default): moving or scaling a brush carries its
## textures with it. Per player, like current_tex; the flag rides each record.
var tex_lock: bool = true
## Texture intent for a new face: the current texture, paraxial, unscaled. Same
## Dictionary shape a brush.create record and DCLevel.add_brush carry.
func current_face_tex() -> Dictionary:
return {"tex": current_tex if current_tex != "" else TexturePacks.default_id(), "mode": 1}
## The texture strip (bottom row of the BuildDock); null on maps without packs.
var _tex_strip: Node = null
## The Face panel (UV controls, right column); same lifetime as the strip.
var _face_panel: Node = null
## The editor bars (redesign E1): top bar (menus, select modes, grid, lock) and toolbar.
var _top_bar: Node = null
var _tool_settings: Node = null
var _history: Node = null
var _view: Node = null
var _cursor3d: Node3D = null
var _object_panel: Node = null
var _shape_panel: Node = null
var _stack_panel: Node = null
var _bot_panel: Node = null
var _tool_bar: Node = null
var _palette: Node = null
var _tool_popup: Node = null
var _context_menu: Node = null
var _q_menu: Node = null
func _register_editor_bars() -> void:
var dock := get_node_or_null("/root/BuildDock")
if dock == null:
return
var top := HFlowContainer.new()
top.set_script(load("res://scenes/game/editor_top_bar.gd"))
top.setup(self)
dock.register("topbar", "Editor", top, true, dock.SIDE_TOP)
_top_bar = top
# E4: the active tool's settings, generated from its tool_settings(), under the top bar.
var ts := HFlowContainer.new()
ts.set_script(load("res://scenes/game/tool_settings_bar.gd"))
ts.setup(self)
dock.register("toolsettings", "Tool", ts, true, dock.SIDE_TOP, dock.WS_LEVEL)
_tool_settings = ts
# And its readout under the crosshair ("+64u X"), polled by the HUD like the status bar.
if BuildHud != null and "readout_provider" in BuildHud:
BuildHud.readout_provider = Callable(self, "_op_readout")
# Orthographic + Blender's numpad views (#109, docs/editor-ortho-view.md).
_view = Node.new()
_view.name = "EditorView"
_view.set_script(load("res://scenes/game/editor_view.gd"))
add_child(_view)
_view.setup(self)
# The 3D cursor (#109, docs/editor-3d-cursor.md).
_cursor3d = Node3D.new()
_cursor3d.set_script(load("res://scenes/game/editor_cursor.gd"))
add_child(_cursor3d)
_cursor3d.setup(self)
# E5: the History panel (right) and Adjust Last (left), both reading the journal.
_ensure_persistence()
_history = Node.new()
_history.name = "EditorHistory"
_history.set_script(load("res://scenes/game/editor_history.gd"))
add_child(_history)
_history.setup(self)
# The selected scene object's properties (object.set_prop). Every map with objects,
# so here with the bars rather than with the pack-only Face panel.
_object_panel = VBoxContainer.new()
_object_panel.set_script(load("res://scenes/game/object_panel.gd"))
_object_panel.setup(self)
dock.register("object", "Object", _object_panel, true, dock.SIDE_RIGHT)
# The selected parametric shape's params, Make Real and Delete (parametric-shapes §4).
_shape_panel = VBoxContainer.new()
_shape_panel.set_script(load("res://scenes/game/shape_panel.gd"))
_shape_panel.setup(self)
dock.register("shape", "Shape", _shape_panel, true, dock.SIDE_RIGHT, dock.WS_LEVEL)
# The selected thing's live cuts, under it (docs/cutting-and-operations.md §9b).
_stack_panel = VBoxContainer.new()
_stack_panel.set_script(load("res://scenes/game/stack_panel.gd"))
_stack_panel.setup(self)
dock.register("stack", "Stack", _stack_panel, true, dock.SIDE_RIGHT, dock.WS_LEVEL)
var tools := HFlowContainer.new()
tools.set_script(load("res://scenes/game/editor_tool_bar.gd"))
tools.setup(self)
# FIRST in the left column — the tools are what you reach for most.
dock.register("tools", "Tools", tools, true, dock.SIDE_LEFT, dock.WS_LEVEL)
var col: Node = dock._sides[dock.SIDE_LEFT]["column"]
var frame: Node = (dock._panels["tools"]["section"] as Node).get_parent()
col.move_child(frame, 0)
_tool_bar = tools
# F3 command palette (E3): every reachable command by name, run through Keymap.run.
_palette = CanvasLayer.new()
_palette.name = "CommandPalette" # its Outliner row, not "@CanvasLayer@322"
_palette.set_script(load("res://scenes/game/command_palette.gd"))
_palette.setup(self)
add_child(_palette)
# Shift+Space tool popup (E3c) and RMB-tap context menu (E3d): one popup script.
_tool_popup = Node.new()
_tool_popup.set_script(load("res://scenes/game/action_popup.gd"))
add_child(_tool_popup)
_tool_popup.setup("ToolPopup")
_context_menu = Node.new()
_context_menu.set_script(load("res://scenes/game/action_popup.gd"))
add_child(_context_menu)
_context_menu.setup("ContextMenu")
# The Q menu (HardOps; docs/cutting-and-operations.md §9c): the same popup, rows by
# what is selected (q_sections).
_q_menu = Node.new()
_q_menu.set_script(load("res://scenes/game/action_popup.gd"))
add_child(_q_menu)
_q_menu.setup("QMenu")
if not GameMode.look_tapped.is_connected(_on_look_tapped):
GameMode.look_tapped.connect(_on_look_tapped)
var hud := get_node_or_null("/root/BuildHud")
if hud != null:
hud.info_provider = _status_info
# The BOT EDITOR workspace's panel (user, 2026-10-04), then the last workspace used.
var bots := VBoxContainer.new()
bots.set_script(load("res://scenes/game/bot_editor_panel.gd"))
bots.setup(self)
dock.register("bots", "Bots", bots, true, dock.SIDE_LEFT, dock.WS_BOT)
_bot_panel = bots
dock.set_workspace(String(dock.pref("workspace", dock.WS_LEVEL)))
## RMB TAP -> the context menu (E3d). Only when no tool is up: while an operator runs
## the map takes RMB first (cancel), so GameMode never sees that tap anyway.
func _on_look_tapped(at: Vector2) -> void:
if _context_menu == null or not GameMode.is_building() or level == null:
return
if _ops != null and _ops.active != null:
return
_context_menu.open_sections(context_sections(), at)
## What the context menu offers, by SELECTION KIND (docs/editor-redesign.md §5.5):
## element (face / edge / vertex) ops, brush ops (+ CSG for two or more), then what is
## always available. Every row is a keymap action, so it shows and runs its key.
func context_sections() -> Array:
var secs: Array = []
if _sel_kind != "":
var el: Array = [["builder.move"], ["builder.move_in"], ["builder.move_out"]]
if _sel_kind == "face":
el.append_array([["builder.extrude"], ["builder.texture_apply"], ["builder.texture_pick"],
["build_mode.faces_extrude"]])
if _sel_faces.size() >= 2:
el.append(["build_mode.faces_hull"])
el.append(["builder.cable_bundle"]) # #120 L3 §14.6
if _sel_kind == "edge":
el.append(["builder.cable_from_edges"]) # #120 L3 §14.5
el.append(["builder.deselect"])
secs.append([_sel_kind.capitalize(), el])
if not _sel_brushes.is_empty():
var br: Array = [["builder.move"], ["builder.duplicate"], ["builder.duplicate_linked"], ["builder.texture_apply"], ["builder.delete"]]
if _sel_brushes.size() >= 2:
br.append_array([["build_mode.csg_subtract"], ["build_mode.csg_intersect"], ["build_mode.csg_merge"],
["build_mode.csg_discard"]])
br.append(["builder.deselect"])
secs.append(["Brush" if _sel_brushes.size() == 1 else "%d brushes" % _sel_brushes.size(), br])
secs.append(["Add & view", [["builder.create_box"], ["builder.knife"], ["builder.carve"],
["builder.snap_toggle"], ["builder.grid_coarser"], ["builder.grid_finer"], ["builder.palette"]]])
secs.append(cursor_section())
return secs
## The 3D cursor's snap commands (Blender's Shift+S menu; editor_cursor.gd).
func cursor_section() -> Array:
return ["3D cursor", [["cursor.to_selected"], ["cursor.to_active"], ["cursor.to_grid"], ["cursor.to_origin"]]]
## THE Q MENU's rows, by selection (docs/cutting-and-operations.md §9c).
func q_sections() -> Array:
if _sel_kind in ["face", "edge", "vertex"]:
var cs: Array = context_sections()
return [cs[0]] if not cs.is_empty() else []
if _sel_brushes.is_empty():
return [["Add", [["builder.create_box"]]], cursor_section()]
if _sel_asset != "":
# A PLACED ASSET is one thing (#120 L4): its pieces are read-only.
return [["Placed asset", [["builder.make_real"], ["builder.duplicate"], ["builder.delete"]]],
["Selection", [["builder.hide"], ["builder.hide_unselected"]]]]
var things := {}
for b: int in _sel_brushes:
var g := shape_of_brush(b)
if g == "":
g = String(level.get_brush_group(b))
things[g if g != "" else "brush:%d" % b] = true
if things.size() >= 2:
return [["Operations (active = target)", [["build_mode.csg_subtract"], ["build_mode.csg_intersect"],
["build_mode.csg_slice"], ["build_mode.csg_merge"], ["build_mode.csg_discard"]]],
["Selection", [["builder.hide"], ["builder.hide_unselected"], ["builder.delete"]]]]
var subject := stack_subject()
if not cuts_by(subject).is_empty():
return [["Cutter", [["builder.cut_as_subtract"], ["builder.cut_as_intersect"], ["builder.cut_as_slice"],
["builder.select_cut_target"], ["builder.cuts_clear"]]],
["Selection", [["builder.hide"], ["builder.hide_unselected"]]]]
_ensure_persistence()
var is_shape: bool = (_persist.live_shapes() as Dictionary).has(subject)
var rows: Array = []
if is_shape:
rows.append(["builder.assign_generator"])
rows.append(["builder.add_modifier"])
rows.append(["builder.make_real"])
if String(_persist.shape_state(subject).get("type", "")) == "cable":
rows.append(["builder.drop_cable"]) # #120 L3 §14.3
else:
rows.append(["builder.make_parametric"])
if not op_stack(subject).is_empty():
rows.append(["builder.cuts_clear"])
# #120 L2: the stack operations (StackOps); Apply works on shapes, as Make Real does.
rows.append(["builder.change_cut"])
rows.append(["builder.extract_cut"])
if is_shape:
rows.append(["builder.apply_up_to"])
rows.append_array([["builder.hide"], ["builder.hide_unselected"], ["builder.delete"]])
return [["Shape" if is_shape else "Selection", rows], cursor_section()]
## ---- KEYMAP STEP 3 commands (docs/keymap-blender-279.md §3a) ----
## The parametric shape types the add menu offers (param_shapes ids, tab order).
const ADD_SHAPES := ["box", "stairs", "spiral_stairs", "tunnel", "cylinder", "cone", "sphere"]
## What Shift+R repeats: the commands that start a tool or apply an edit (not views, not
## selection, not menus). Recorded as they fire (_note_repeatable).
const REPEATABLE := ["builder.move", "builder.rotate", "builder.scale", "builder.extrude",
"builder.duplicate", "builder.duplicate_linked", "builder.knife", "builder.create_box", "builder.box_cutter", "builder.texture_apply",
"build_mode.csg_subtract", "build_mode.csg_intersect", "build_mode.csg_slice",
"build_mode.csg_merge", "builder.uv_hotspot", "builder.uv_fit", "builder.uv_align"]
var _last_repeatable := ""
## The element mode Tab returns to from brush mode (1-3).
var _last_element_mode := "face"
func _note_repeatable(event: InputEvent) -> void:
for a: String in REPEATABLE:
if Keymap.tapped(event, a):
_last_repeatable = a
return
## One select-mode switch for the 1-4 keys and Tab.
func set_select_mode(mode: String) -> void:
if not SEL_MODES.has(mode):
return
if mode != "brush":
_last_element_mode = mode
if _sel_mode != mode:
_sel_mode = mode
# CLEARED, not converted. Blender converts a selection between modes (a face
# becomes its four vertices); here a selection IS a set of incident planes, and
# converting one would silently change what the next drag moves. Clearing says so.
# A selected CURVE is kept (§17.7 Q4): it is a whole object, and the mode is what
# decides whether G edits its points (1) or moves it (4).
var keep_curve := _sel_curve
_clear_face_selection()
if keep_curve != "":
select_curve(keep_curve)
_sync_handle_visibility()
_update_op_hud()
print("[MapDCLevel] select mode: %s" % mode)
## ---- THE PIVOT (keymap step 6.2; Blender 2.79's transform_pivot_point) ----
## , bounding box centre (the default, and the old rotate centre) Ctrl+, median point
## . the 3D cursor Alt+. active element
## Ctrl+. = INDIVIDUAL ORIGINS (#109 step 6): each UNIT turns / scales about its own
## centre, preview included — a parametric shape is one unit (its pieces together), a
## plain brush is one, an object turns about its own origin. See individual_units.
const PIVOT_MODES := ["bbox", "median", "cursor", "active", "individual"]
const PIVOT_NAMES := ["Bounding box", "Median", "3D cursor", "Active", "Individual origins"]
var pivot_mode := "bbox"
func set_pivot_mode(m: String) -> void:
if not PIVOT_MODES.has(m):
return
pivot_mode = m
if BuildDock != null:
BuildDock.set_pref("pivot_mode", m)
print("[MapDCLevel] pivot: %s" % PIVOT_NAMES[PIVOT_MODES.find(m)])
func pivot_index() -> int:
return PIVOT_MODES.find(pivot_mode)
func pivot_individual() -> bool:
return pivot_mode == "individual"
## ---- PROPORTIONAL EDITING (O; Blender use_proportional_edit_objects; design on
## hyper-games #109, 2026-10-13) --------------------------------------------------
##
## With it on, G / R / S move what is NEAR the selection too, by a falloff of its
## distance: w = falloff(1 - d / radius), d from the nearest selected unit. A unit is
## what moves as one — a plain brush, a parametric shape (its pieces together), a scene
## object — so every brush stays a valid convex solid. Keys as Blender 2.7x: O toggles,
## Shift+O cycles the falloff, Alt+O connected-only (the element level's); PgUp / PgDn
## size the radius while a tool is up.
const PROP_FALLOFFS: Array[String] = ["smooth", "sphere", "root", "sharp", "linear", "constant"]
var proportional := false
var prop_falloff := "smooth"
var prop_radius := 8.0 # metres
var prop_connected := false
func toggle_proportional() -> void:
proportional = not proportional
print("[MapDCLevel] proportional editing %s (%s, radius %.1f m)" % ["ON" if proportional else "off",
prop_falloff, prop_radius])
_update_op_hud()
func cycle_prop_falloff() -> void:
prop_falloff = PROP_FALLOFFS[(PROP_FALLOFFS.find(prop_falloff) + 1) % PROP_FALLOFFS.size()]
print("[MapDCLevel] proportional falloff: %s" % prop_falloff)
_update_op_hud()
func toggle_prop_connected() -> void:
prop_connected = not prop_connected
print("[MapDCLevel] proportional: %s" % ("connected only" if prop_connected else "everything in range"))
_update_op_hud()
## Blender's falloffs of t = 1 - d / r (1 at the selection, 0 at the radius).
static func prop_weight(falloff: String, d: float, r: float) -> float:
if r <= 0.0 or d > r or (d >= r and falloff != "constant"):
return 0.0
var t := 1.0 - d / r
match falloff:
"sharp":
return t * t
"root":
return sqrt(t)
"linear":
return t
"constant":
return 1.0
"sphere":
return sqrt(maxf(0.0, 2.0 * t - t * t))
return 3.0 * t * t - 2.0 * t * t * t # smooth
## The units near the selection, weighted: [{kind: "brush"|"shape"|"object", key | shape |
## dot, w}]. `sel_ids` are selected brush ids (a shape's pieces count as the shape),
## `sel_dots` selected objects. Distances are WORLD-space: brushes live in the level
## store's space, objects in SceneObjects' — measured where they actually are.
func proportional_neighbours(sel_ids: Array, sel_dots: Array) -> Array:
var out: Array = []
if not proportional or level == null:
return out
var lg := (level as Node3D).global_transform
var from := PackedVector3Array()
var skip_brush := {}
var skip_shape := {}
for u: Dictionary in individual_units(sel_ids):
from.append(lg * (u["centre"] as Vector3))
if String(u["shape"]) != "":
skip_shape[String(u["shape"])] = true
for b: int in (u["ids"] as PackedInt32Array):
skip_brush[b] = true
for d: String in sel_dots:
var n := object_node(d) as Node3D
if n != null:
from.append(n.global_position)
if from.is_empty():
return out
var near := func(p: Vector3) -> float:
var best := INF
for f: Vector3 in from:
best = minf(best, p.distance_to(f))
return best
var others: Array = []
for b: int in level.get_live_brush_ids():
if not skip_brush.has(b) and not bool(level.get_brush(b).get("clearance", false)):
others.append(b)
for u: Dictionary in individual_units(others):
if String(u["shape"]) != "" and skip_shape.has(String(u["shape"])):
continue
var w := prop_weight(prop_falloff, near.call(lg * (u["centre"] as Vector3)), prop_radius)
if w > 0.0:
out.append({"kind": "shape" if String(u["shape"]) != "" else "brush", "shape": u["shape"],
"key": u["key"], "w": w})
if _objects != null and is_instance_valid(_objects):
for e: Dictionary in _objects.entries():
var dot := String(e["dot"])
if sel_dots.has(dot) or String(e["parent"]) != "":
continue # a child rides with its parent
var w2 := prop_weight(prop_falloff, near.call((e["node"] as Node3D).global_position), prop_radius)
if w2 > 0.0:
out.append({"kind": "object", "dot": dot, "w": w2})
return out
## Move proportional neighbours (brushes and shapes) by their weight × `delta`. Objects
## are the caller's (they move with the selection's object path). The operator stack
## wraps the confirm in one gesture: one Ctrl+Z takes all of it back.
func translate_units(units: Array, delta: Vector3) -> int:
var n := 0
for u: Dictionary in units:
var d := delta * float(u["w"])
match String(u["kind"]):
"shape":
var sd := String(u["shape"])
var f: Transform3D = _persist.shape_state(sd)["frame"]
if _record_shape_frame(sd, Transform3D(f.basis, f.origin + d)):
n += 1
"brush":
var b: int = level.brush_by_key(String(u["key"]))
if b >= 0 and level.translate_brush(b, d, tex_lock):
_persist.record("brush.translate", {"brush_key": String(u["key"]),
"delta": [d.x, d.y, d.z], "tex_lock": tex_lock})
n += 1
if n > 0:
_persist.refold()
_refresh_brush_view()
return n
## Turn ("rotate": `axis`, `deg`) or scale ("scale": `factor`) proportional neighbours
## about `pivot`, each by its weight: w·deg, or factor lerped from 1 by w. Each unit is
## resolved by its durable name at its turn — every rotate / scale refolds, and a refold
## can renumber the ids. Objects are the caller's. One gesture: the operator stack's.
func transform_units(units: Array, op: String, axis: Vector3, deg: float, factor: Vector3, pivot: Vector3) -> int:
var n := 0
for u: Dictionary in units:
var ids: Array = []
match String(u["kind"]):
"shape":
ids = Array(level.get_group_brush_ids(String(u["shape"])))
"brush":
var b: int = level.brush_by_key(String(u["key"]))
if b >= 0:
ids = [b]
if ids.is_empty():
continue
var w := float(u["w"])
if op == "rotate":
n += rotate_brushes(ids, axis, deg * w, pivot)
else:
n += scale_brushes(ids, Vector3.ONE.lerp(factor, w), pivot)
return n
## PROPORTIONAL, ELEMENT LEVEL (#109; user 2026-10-13: "also element vertices"). The
## corners near a part about to move — collected BEFORE it moves — as [brush key, corner,
## w]: on its own brush, and (unless connected-only, Alt+O) on the brushes beside it, so
## shared seams follow. `src` = the part's own corners (they move with the part; never
## counted twice). A shape's pieces are skipped: read-only (guard_shape_part).
func prop_corners(src: PackedVector3Array, own: int) -> Array:
var out: Array = []
if not proportional or level == null or src.is_empty():
return out
var brushes: Array = [own] if prop_connected else Array(level.get_live_brush_ids())
for b: int in brushes:
if b < 0 or bool(level.get_brush(b).get("clearance", false)) or shape_of_brush(b) != "":
continue
var key := String(level.get_brush_key(b))
for v: Vector3 in level.get_brush_vertices_of(b):
var best := INF
for s: Vector3 in src:
best = minf(best, v.distance_to(s))
if best < 1.0e-3:
continue # one of the part's own corners
var w := prop_weight(prop_falloff, best, prop_radius)
if w > 0.0:
out.append([key, v, w])
return out
## Move each collected corner by w·delta — one brush.reshape (re-hull) each, as element
## rotate moves corners. A corner the part's own move already carried (a face or a
## nudge moves planes, which drags corners) is no longer where it was: skipped.
func prop_move_corners(corners: Array, delta: Vector3) -> int:
var n := 0
for e: Array in corners:
var b: int = level.brush_by_key(String(e[0]))
if b < 0:
continue
var c: Vector3 = e[1]
var still := false
for v: Vector3 in level.get_brush_vertices_of(b):
if v.distance_to(c) < 1.0e-3:
still = true
break
if not still:
continue
var dl: Vector3 = delta * float(e[2])
var dot: String = _persist.reserve_dot()
if level.move_brush_points(b, PackedVector3Array([c]), dl, dot):
_persist.record_at(dot, "brush.reshape", {"brush_key": String(e[0]),
"points": [[c.x, c.y, c.z]], "delta": [dl.x, dl.y, dl.z]})
n += 1
if n > 0:
level.rebuild()
_refresh_brush_view()
return n
## The selected part's own corners: a vertex / edge's drag points, a face's polygon.
func element_corners() -> PackedVector3Array:
var pts: PackedVector3Array = element_drag_points()
if pts.is_empty() and _sel_kind == "face":
var idx: PackedInt32Array = element_planes()
if not idx.is_empty():
pts = level.get_face_winding(_sel_brush, idx[0])
return pts
## The UNITS among brushes `ids` for individual origins, each with its own centre (the
## centre of its non-clearance bounds), in the level store's space:
## [{"key": brush key or "", "shape": dot or "", "ids": PackedInt32Array, "centre": Vector3,
## "size": Vector3 (those bounds' size)}]
## Held by DURABLE names: a shape edit refolds the level and renumbers brush ids, so a
## caller acting on several units in turn re-resolves each one (unit_ids) at its turn.
func individual_units(ids: Array) -> Array:
var out: Array = []
var sp: Array = _split_shapes(ids)
for d: String in (sp[0] as Dictionary):
var pieces: PackedInt32Array = level.get_group_brush_ids(d)
var bs := _unit_bounds(pieces)
out.append({"key": "", "shape": d, "ids": pieces, "centre": bs.get_center(), "size": bs.size})
for pd: String in (sp[2] as Dictionary): # a placed asset is one unit (#120 L4)
var aps := asset_brush_ids(pd)
var ab := _unit_bounds(aps)
out.append({"key": "", "shape": "", "asset": pd, "ids": aps, "centre": ab.get_center(), "size": ab.size})
for b: int in (sp[1] as Array):
var one := PackedInt32Array([b])
var bb := _unit_bounds(one)
out.append({"key": String(level.get_brush_key(b)), "shape": "", "ids": one,
"centre": bb.get_center(), "size": bb.size})
return out
## A unit's CURRENT brush ids (re-resolved by its durable name).
func unit_ids(u: Dictionary) -> PackedInt32Array:
if String(u.get("asset", "")) != "":
return asset_brush_ids(String(u["asset"]))
if String(u["shape"]) != "":
return level.get_group_brush_ids(String(u["shape"]))
var b: int = level.brush_by_key(String(u["key"]))
return PackedInt32Array([b]) if b >= 0 else PackedInt32Array()
func _unit_bounds(ids: PackedInt32Array) -> AABB:
var lo := Vector3.INF
var hi := -Vector3.INF
for b: int in ids:
var d: Dictionary = level.get_brush(b)
if not d.has("aabb_min") or bool(d.get("clearance", false)):
continue
lo = lo.min(d["aabb_min"] as Vector3)
hi = hi.max(d["aabb_max"] as Vector3)
return AABB(lo, hi - lo) if lo.x != INF else AABB()
## Turn every unit about its OWN centre (one gesture — the operator stack's).
func rotate_individual(ids: Array, axis: Vector3, deg: float) -> int:
var n := 0
for u: Dictionary in individual_units(ids):
var now := unit_ids(u)
if not now.is_empty():
n += rotate_brushes(Array(now), axis, deg, u["centre"])
return n
## Scale every unit (individual_units, read BEFORE the first refold) about its OWN centre,
## unit i by factors[i] — each its own grid step. `local`: along the shape's own axes.
## One gesture — the operator stack's.
func scale_individual(units: Array, factors: Array, local := false) -> int:
var n := 0
for i in mini(units.size(), factors.size()):
var u: Dictionary = units[i]
var now := unit_ids(u)
if now.is_empty():
continue
if local:
n += scale_shapes_local(Array(now), factors[i], u["centre"])
else:
n += scale_brushes(Array(now), factors[i], u["centre"])
return n
## The pivot for brushes `ids`, in the LEVEL STORE's space (where brush planes live).
func pivot_point(ids: Array) -> Vector3:
var lo := Vector3.INF
var hi := -Vector3.INF
var sum := Vector3.ZERO
var n := 0
for b: Variant in ids:
var d: Dictionary = level.get_brush(int(b))
if not d.has("aabb_min"):
continue
lo = lo.min(d["aabb_min"] as Vector3)
hi = hi.max(d["aabb_max"] as Vector3)
sum += ((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5
n += 1
var bbox := (lo + hi) * 0.5 if n > 0 else Vector3.ZERO
match pivot_mode:
"median":
return sum / float(n) if n > 0 else bbox
"cursor":
if _cursor3d != null:
return (level as Node3D).global_transform.affine_inverse() * _cursor3d.global_position
"active":
if _sel_brush >= 0 and ids.has(_sel_brush):
var a: Dictionary = level.get_brush(_sel_brush)
if a.has("aabb_min"):
return ((a["aabb_min"] as Vector3) + (a["aabb_max"] as Vector3)) * 0.5
return bbox
## The pivot for scene OBJECTS by their origins (scene space); `active` = the active one's.
func pivot_point_objects(origins: Array, active: Variant = null) -> Vector3:
var lo := Vector3.INF
var hi := -Vector3.INF
var sum := Vector3.ZERO
for o: Vector3 in origins:
lo = lo.min(o)
hi = hi.max(o)
sum += o
var bbox := (lo + hi) * 0.5 if not origins.is_empty() else Vector3.ZERO
match pivot_mode:
"median":
return sum / float(origins.size()) if not origins.is_empty() else bbox
"cursor":
if _cursor3d != null:
var space: Node3D = _objects as Node3D if _objects is Node3D else null
return space.global_transform.affine_inverse() * _cursor3d.global_position if space != null \
else _cursor3d.global_position
"active":
if active is Vector3:
return active
return bbox
## ---- SELECT LINKED / GROUPED (keymap step 6.3) ----
## A brush's "linked" unit: every live brush of its group (a shape's pieces, an operator
## group), else just itself.
func _unit_of(b: int) -> PackedInt32Array:
var g := String(level.get_brush_group(b))
if g != "":
var ids: PackedInt32Array = level.get_group_brush_ids(g)
if not ids.is_empty():
return ids
return PackedInt32Array([b])
## L / Shift+L: add (remove) the shape / group under the POINTER to (from) the selection.
func select_linked_pick(deselect := false) -> int:
var hit: Dictionary = _crosshair_hit(get_viewport().get_mouse_position())
var b: int = _brush_from_hit(hit) if not hit.is_empty() else -1
if b < 0:
print("[MapDCLevel] select linked: nothing under the pointer")
return 0
var unit := _unit_of(b)
var out := PackedInt32Array(_sel_brushes)
for id: int in unit:
if deselect:
var at := out.find(id)
if at >= 0:
out.remove_at(at)
elif not out.has(id):
out.append(id)
select_brushes(out)
return unit.size()
## Ctrl+L: grow the selection to EVERYTHING TOUCHING IT, transitively (AABB contact — the
## store's own `connected` test). Bucketed on a coarse grid so a big level stays fast.
func select_linked() -> int:
if level == null or _sel_brushes.is_empty():
return 0
const CELL := 4.0
var box := {}
var buckets := {}
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
var d: Dictionary = level.get_brush(id)
if not d.has("aabb_min") or bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
continue
var lo: Vector3 = d["aabb_min"]
var hi: Vector3 = d["aabb_max"]
box[id] = [lo, hi]
for cx in range(floori(lo.x / CELL), floori(hi.x / CELL) + 1):
for cz in range(floori(lo.z / CELL), floori(hi.z / CELL) + 1):
var k := Vector2i(cx, cz)
if not buckets.has(k):
buckets[k] = []
(buckets[k] as Array).append(id)
var seen := {}
var queue: Array = []
for b: int in _sel_brushes:
if box.has(b):
seen[b] = true
queue.append(b)
const EPS := 1e-3
while not queue.is_empty():
var a: int = queue.pop_back()
var alo: Vector3 = box[a][0]
var ahi: Vector3 = box[a][1]
for cx in range(floori(alo.x / CELL), floori(ahi.x / CELL) + 1):
for cz in range(floori(alo.z / CELL), floori(ahi.z / CELL) + 1):
for c: int in (buckets.get(Vector2i(cx, cz), []) as Array):
if seen.has(c):
continue
var blo: Vector3 = box[c][0]
var bhi: Vector3 = box[c][1]
if ahi.x + EPS < blo.x or alo.x - EPS > bhi.x or ahi.y + EPS < blo.y \
or alo.y - EPS > bhi.y or ahi.z + EPS < blo.z or alo.z - EPS > bhi.z:
continue
seen[c] = true
queue.append(c)
select_brushes(PackedInt32Array(seen.keys()))
print("[MapDCLevel] select linked: %d brush(es) touching the selection" % seen.size())
return seen.size()
## X (step 6.4): Blender's confirming delete — the popup IS the confirm, its row names what
## goes. Clear cuts rides along when the selection carries live cuts.
func delete_sections() -> Array:
var n := _sel_objects.size() + _sel_brushes.size()
if n == 0:
return [["Delete", [["builder.delete", "Delete the aimed brush"]]]]
var rows: Array = [["builder.delete", "Delete %d selected" % n]]
# X keeps an object's children (#120 F1); Delete Hierarchy takes them too, offered
# only when a selected object has any.
if _objects != null and is_instance_valid(_objects):
for e: Dictionary in (_objects.entries() as Array):
if _sel_objects.has(String(e.get("parent", ""))):
rows.append(["builder.delete_hierarchy"])
break
var subj := stack_subject()
if subj != "" and (not op_stack(subj).is_empty() or not cuts_by(subj).is_empty()):
rows.append(["builder.cuts_clear"])
return [["Delete?", rows]]
## Ctrl+G / M / Shift+M / Ctrl+Alt+G / Ctrl+J / P (#120 F1). True if the event was one.
## M and Shift+M pick a collection from a list (Blender's, with + New Collection on top);
## Ctrl+Alt+G lists only the collections the selection is in.
func _collection_keys(event: InputEvent) -> bool:
if Keymap.tapped(event, "builder.collection_new"):
# Collection rows selected in the outliner are wrapped (a meta collection).
var cols: Array = _outliner.selected_collections() if _outliner != null and is_instance_valid(_outliner) else []
prompt_collection_name("New collection", func(n: String) -> void:
var dot := CollectionOps.create_from_selection(self, n, "", cols)
print("[MapDCLevel] new collection %s \"%s\" from the selection" % [dot, n]))
return true
if Keymap.tapped(event, "builder.save_asset"):
save_asset_prompt()
return true
if Keymap.tapped(event, "builder.join"):
CollectionOps.join(self)
return true
if Keymap.tapped(event, "builder.rename"):
if _outliner != null and is_instance_valid(_outliner):
_outliner.rename_selected()
return true
if Keymap.tapped(event, "builder.separate"):
CollectionOps.separate(self)
return true
for spec: Array in [["builder.collection_move", "Move to collection", "move"],
["builder.collection_link", "Link to collection", "link"],
["builder.collection_remove", "Remove from collection", "remove"]]:
if Keymap.tapped(event, String(spec[0])):
if _q_menu != null:
_q_menu.open_choices(String(spec[1]), collection_choices(String(spec[2])),
get_viewport().get_mouse_position())
get_viewport().set_input_as_handled()
return true
return false
## ---- ASSETS (#120 L4 §15.4) ----
const AssetLibraryScript := preload("res://scenes/maps/asset_library.gd")
var _asset_library: Node = null
## The room's + personal asset libraries (a child; created with the journal so placed
## assets' blobs are fetched whether or not a panel is open).
func asset_library() -> Node:
_ensure_persistence()
return _asset_library
## The collection Save as asset acts on: the outliner's selected collection row, else the
## first collection the selection is in. "" when neither.
func asset_collection() -> String:
_ensure_persistence()
var cols: Array = _outliner.selected_collections() if _outliner != null and is_instance_valid(_outliner) else []
if not cols.is_empty():
return String(cols[0])
# The selection's members, READ ONLY (CollectionOps.selection_members regroups loose
# brushes — a write — and asking which collection must not write).
var ms: Array = []
if _sel_asset != "":
ms.append("a:" + _sel_asset)
if _sel_shape != "":
ms.append("g:" + _sel_shape)
for d: Variant in selected_objects():
ms.append("obj:" + String(d))
for b: int in _sel_brushes:
var g := String(level.get_brush_group(b)) if level != null else ""
if g != "" and not ms.has("g:" + g):
ms.append("g:" + g)
var t: Dictionary = _persist.collection_tree()
for m: String in ms:
var cs: Array = CollectionFold.collections_of(t, m)
if not cs.is_empty():
return String(cs[0])
return ""
## SAVE COLLECTION AS ASSET (menu only, user 2026-10-19): named first (Blender's popup,
## prefilled with the collection's name), then into the room's and your library.
func save_asset_prompt() -> void:
var col := asset_collection()
if col == "":
print("[MapDCLevel] save as asset: select a collection (its outliner row, or something in it)")
return
var cname := String(((_persist.collection_tree()["cols"] as Dictionary)[col] as Dictionary)["name"])
var make := func(n: String) -> void:
var r: Dictionary = asset_library().save_collection(col, n, asset_save_room, asset_save_personal)
for line: String in (r.get("report", []) as Array):
print("[MapDCLevel] save as asset: %s" % line)
if r.has("error"):
print("[MapDCLevel] save as asset \"%s\": %s" % [n, r["error"]])
else:
print("[MapDCLevel] saved asset \"%s\" %s (%d of your placements updated)"
% [n, String(r["oid"]).substr(0, 12), int(r["followed"])])
if _q_menu == null or not _q_menu.has_method("open_name"):
make.call(cname)
return
_q_menu.open_name("Save as asset", cname, make, get_viewport().get_mouse_position())
get_viewport().set_input_as_handled()
## ---- the ASSET BROWSER's side (#120 L4 §15.5) ----
## The asset Shift+A → Asset places (a browser click picks it). Per player, a tool setting.
var current_asset := ""
## Where Save as asset writes: the browser's "Save to" switches. Both by default (§15.4).
var asset_save_room := true
var asset_save_personal := true
var _asset_browser: Node = null
var _asset_ghost: MeshInstance3D = null
## A placement frame for a drop on a surface (§15.5, user 2026-10-19): standing ON the hit,
## up = the hit normal snapped to the nearest world axis (floor stands, wall sticks out,
## ceiling hangs; a slope takes its nearest axis). Right-handed: x then z = x × up.
static func drop_frame(position: Vector3, normal: Vector3) -> Transform3D:
var a := normal.abs()
var up := Vector3.UP
if a.x >= a.y and a.x >= a.z:
up = Vector3(signf(normal.x), 0, 0)
elif a.z >= a.y:
up = Vector3(0, 0, signf(normal.z))
else:
up = Vector3(0, signf(normal.y) if normal.y != 0.0 else 1.0, 0)
# x: world +X on a floor or ceiling; on a wall, world up × the wall normal (level).
var x := Vector3.RIGHT if absf(up.y) > 0.5 else Vector3.UP.cross(up)
return Transform3D(Basis(x, up, x.cross(up)), position)
## Place asset `name` on the surface `hit` ({position, normal}) — the drop. Selected after,
## so G / R adjust it. Returns the placement's dot, or "".
func place_asset_hit(name: String, hit: Dictionary) -> String:
if hit.is_empty() or not hit.has("position"):
return ""
var d: String = asset_library().place(name, drop_frame(hit["position"], hit.get("normal", Vector3.UP)))
if d != "":
select_asset(d)
print("[MapDCLevel] placed asset \"%s\" %s" % [name, d])
return d
## The drop at screen point `at` (viewport coordinates): what is under the pointer.
func place_asset_at(name: String, at: Vector2) -> String:
asset_ghost_hide()
return place_asset_hit(name, _crosshair_hit(at))
## Shift+A → Asset (menu-only): the current asset at the 3D cursor, standing up.
func place_current_asset() -> String:
if current_asset == "":
print("[MapDCLevel] place asset: pick one in the Assets strip first")
return ""
var at: Vector3 = (_cursor3d as Node3D).global_position if _cursor3d is Node3D else Vector3.ZERO
return place_asset_hit(current_asset, {"position": at, "normal": Vector3.UP})
## The drop GHOST while a tile is dragged over the view: the asset's bounds box standing
## where it would land. False (and hidden) when the pointer is over nothing.
func asset_ghost(name: String, at: Vector2) -> bool:
var hit := _crosshair_hit(at)
var bb := _asset_local_bounds(name)
if hit.is_empty() or bb.size == Vector3.ZERO:
asset_ghost_hide()
return false
if _asset_ghost == null or not is_instance_valid(_asset_ghost):
_asset_ghost = MeshInstance3D.new()
_asset_ghost.name = "AssetGhost"
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(0.4, 0.8, 1.0, 0.25)
_asset_ghost.material_override = mat
_asset_ghost.mesh = BoxMesh.new()
add_child(_asset_ghost)
(_asset_ghost.mesh as BoxMesh).size = bb.size
_asset_ghost.global_transform = drop_frame(hit["position"], hit.get("normal", Vector3.UP)) \
* Transform3D(Basis.IDENTITY, bb.get_center())
_asset_ghost.visible = true
return true
func asset_ghost_hide() -> void:
if _asset_ghost != null and is_instance_valid(_asset_ghost):
_asset_ghost.visible = false
## An asset's bounds relative to its pivot (the base centre), from the library row.
func _asset_local_bounds(name: String) -> AABB:
for r: Dictionary in (asset_library().rows() as Array):
if String(r["name"]) == name:
var b: Array = r.get("bounds", [])
if b.size() == 6:
var lo := Vector3(float(b[0]), float(b[1]), float(b[2]))
var hi := Vector3(float(b[3]), float(b[4]), float(b[5]))
var piv := Vector3((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5)
return AABB(lo - piv, hi - lo)
return AABB(Vector3(-0.5, 0, -0.5), Vector3.ONE) # personal-only: no catalogue bounds
return AABB()
func _register_asset_browser() -> void:
var dock := get_node_or_null("/root/BuildDock")
if dock == null or (_asset_browser != null and is_instance_valid(_asset_browser)):
return
var ab := VBoxContainer.new()
ab.set_script(load("res://scenes/game/asset_browser.gd"))
ab.setup(self)
dock.register("assets", "Assets", ab, true, dock.SIDE_BOTTOM, dock.WS_LEVEL)
_asset_browser = ab
## A NEW COLLECTION'S NAME (user 2026-10-18: "when creating a new group i should be
## prompted for a name"), Blender's popup: make(name) runs on Enter, nothing on Esc, so the
## collection is made by ONE gesture or not at all. No popup (bare harnesses): "Collection".
func prompt_collection_name(title: String, make: Callable) -> void:
if _q_menu == null or not _q_menu.has_method("open_name"):
make.call("Collection")
return
var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_q_menu.open_name(title, "Collection", make, at)
if get_viewport() != null:
get_viewport().set_input_as_handled()
## The picker's rows for `mode` (move | link | remove): [[text, Callable]]. Nested
## collections are indented under their parents; move and link start with
## + New Collection, remove lists only collections the selection is linked into.
func collection_choices(mode: String) -> Array:
_ensure_persistence()
var t: Dictionary = _persist.collection_tree()
var cols: Dictionary = t["cols"]
var rows: Array = []
if mode != "remove":
rows.append(["+ New Collection", func() -> void:
prompt_collection_name("New collection", func(n: String) -> void:
var c := CollectionOps.create_empty(self, n)
if mode == "move":
CollectionOps.move_selection(self, c)
else:
CollectionOps.link_selection(self, c))])
var in_sel := {}
if mode == "remove":
for m: String in _selection_member_names():
for c: String in CollectionFold.collections_of(t, m):
in_sel[c] = true
var roots: Array = []
for d: String in cols:
if String(cols[d]["parent"]) == "":
roots.append(d)
roots.sort()
var stack: Array = []
for i in range(roots.size() - 1, -1, -1):
stack.append([roots[i], 0])
while not stack.is_empty():
var top: Array = stack.pop_back()
var d: String = top[0]
if mode != "remove" or in_sel.has(d):
var label := " ".repeat(int(top[1])) + String(cols[d]["name"])
match mode:
"move":
rows.append([label, func() -> void: CollectionOps.move_selection(self, d)])
"link":
rows.append([label, func() -> void: CollectionOps.link_selection(self, d)])
"remove":
rows.append([label, func() -> void: CollectionOps.unlink_selection(self, d)])
var kids: Array = cols[d]["children"]
for i in range(kids.size() - 1, -1, -1):
stack.append([kids[i], int(top[1]) + 1])
return rows
## The selection's members WITHOUT regrouping loose brushes (a loose brush is in no
## collection yet): for listing which collections it is in.
func _selection_member_names() -> Array:
var out: Array = []
for d: String in selected_objects():
out.append("obj:" + d)
if _sel_asset != "":
out.append("a:" + _sel_asset) # a placed asset (#120 L4)
return out
if _sel_shape != "":
out.append("g:" + _sel_shape)
for b: int in _sel_brushes:
var g := String(level.get_brush_group(b))
if g != "" and not out.has("g:" + g):
out.append("g:" + g)
return out
## Shift+G: the select-grouped menu's rows.
func grouped_sections() -> Array:
return [["Select grouped (like the active brush)", [["builder.select_same_group"],
["builder.select_same_material"], ["builder.select_same_texture"], ["builder.select_same_type"]]]]
## Every live brush that matches the ACTIVE brush by `kind`: group | material | texture | type.
func select_grouped(kind: String) -> int:
if level == null or _sel_brush < 0:
print("[MapDCLevel] select grouped: select a brush first (the active one is the example)")
return 0
var ref := _sel_brush
var ref_d: Dictionary = level.get_brush(ref)
var want: Variant = null
match kind:
"group":
want = String(level.get_brush_group(ref))
"material":
want = int(ref_d.get("material_id", -1))
"texture":
want = _first_tex(ref_d)
"type":
var sd := shape_of_brush(ref)
want = String((_persist.shape_state(sd) as Dictionary).get("type", "")) if sd != "" else ""
if want == null or (want is String and String(want) == ""):
print("[MapDCLevel] select grouped: the active brush has no %s" % kind)
return 0
var out := PackedInt32Array()
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
var d: Dictionary = level.get_brush(id)
if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
continue
var v: Variant = null
match kind:
"group": v = String(level.get_brush_group(id))
"material": v = int(d.get("material_id", -2))
"texture": v = _first_tex(d)
"type":
var sd2 := shape_of_brush(id)
v = String((_persist.shape_state(sd2) as Dictionary).get("type", "")) if sd2 != "" else ""
if v == want:
out.append(id)
select_brushes(out)
print("[MapDCLevel] select grouped (%s): %d brush(es)" % [kind, out.size()])
return out.size()
static func _first_tex(d: Dictionary) -> String:
for t: Variant in (d.get("face_tex", []) as Array):
if t is Dictionary and String((t as Dictionary).get("tex", "")) != "":
return String(t["tex"])
return ""
## Ctrl+I: every live brush NOT selected now.
func select_invert() -> void:
if level == null:
return
var had := {}
for b: int in _sel_brushes:
had[b] = true
var out := PackedInt32Array()
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
if not had.has(id):
out.append(id)
select_brushes(out)
## The add menu (Shift+A): one row per shape type -> the create tool on that shape.
func add_sections() -> Array:
var rows: Array = []
for s: String in ADD_SHAPES:
rows.append(["builder.add_%s" % s])
return [["Add shape", rows], ["Other", [["builder.add_curve"], ["builder.add_cable"], ["builder.cable_from_edges"],
["builder.cable_bundle"], ["builder.carve"], ["builder.place_asset"]]]]
## Start the create tool already set to `shape_id`.
func start_create(shape_id: String) -> void:
if _box == null:
return
var i: int = (_box.shape_ids() as Array).find("param:%s" % shape_id)
if i < 0:
print("[MapDCLevel] add: no shape type %s" % shape_id)
return
_box.set_tool_setting("shape", i)
Keymap.run("builder.create_box")
## The U menu: every texture command, each with its own key shown.
func uv_sections() -> Array:
return [["Texture", [["builder.texture_apply"], ["builder.texture_pick"]]],
["Hotspot", [["builder.uv_hotspot"], ["builder.uv_reroll"], ["builder.uv_pin"]]],
["Align", [["builder.uv_fit"], ["builder.uv_align"]]]]
## Z: Blender 2.79's wireframe toggle — the same switch as the global debug key.
func toggle_wireframe() -> void:
RenderingServer.set_debug_generate_wireframes(true)
var vp := get_viewport()
vp.debug_draw = (Viewport.DEBUG_DRAW_DISABLED if vp.debug_draw == Viewport.DEBUG_DRAW_WIREFRAME
else Viewport.DEBUG_DRAW_WIREFRAME)
print("[MapDCLevel] wireframe %s" % ("on" if vp.debug_draw == Viewport.DEBUG_DRAW_WIREFRAME else "off"))
## ---- BOX SELECT (B) — drag a rectangle; brushes whose centre falls in it. Shift adds.
var _box_sel := false
var _box_from := Vector2.ZERO
var _box_dragging := false
var _box_layer: CanvasLayer = null
var _box_rect: ColorRect = null
func begin_box_select() -> void:
_box_sel = true
_box_dragging = false
print("[MapDCLevel] box select: drag a rectangle (Shift adds, Esc / RMB cancels)")
func is_box_selecting() -> bool:
return _box_sel
func _box_show(r: Rect2) -> void:
if _box_layer == null:
_box_layer = CanvasLayer.new()
_box_layer.layer = 90
add_child(_box_layer)
_box_rect = ColorRect.new()
_box_rect.color = Color(1.0, 0.7, 0.2, 0.15)
_box_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
_box_layer.add_child(_box_rect)
_box_rect.visible = r.size != Vector2.ZERO
_box_rect.position = r.position
_box_rect.size = r.size
## Handles the box-select drag. True when it took the event.
func _box_select_input(event: InputEvent) -> bool:
if not _box_sel:
return false
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_box_sel = false
_box_show(Rect2())
return true
if mb.button_index != MOUSE_BUTTON_LEFT:
return false
if mb.pressed:
_box_from = mb.position
_box_dragging = true
_box_show(Rect2(_box_from, Vector2.ZERO))
elif _box_dragging:
box_select(Rect2(_box_from, Vector2.ZERO).expand(mb.position), mb.shift_pressed)
_box_sel = false
_box_dragging = false
_box_show(Rect2())
return true
if event is InputEventMouseMotion and _box_dragging:
_box_show(Rect2(_box_from, Vector2.ZERO).expand((event as InputEventMouseMotion).position))
return true
return false
## ---- CIRCLE SELECT (C) — Blender 2.79's view3d.select_circle (#109 step 6) ----------
## A ring follows the pointer. LMB drag PAINTS brushes into the selection, Shift+LMB
## paints them out; the wheel or [ ] resizes the ring; RMB / Esc / Enter leaves, the
## selection kept. A brush counts when its centre projects inside the ring (box
## select's rule).
var _circ_sel := false
var _circ_radius := 40.0 # screen pixels
var _circ_pos := Vector2.ZERO
var _circ_painting := false
var _circ_sub := false
var _circ_layer: CanvasLayer = null
var _circ_ui: Control = null
const CIRC_MIN := 8.0
const CIRC_MAX := 600.0
## Letters by character, [ ] by position (keymap step 5) — as LevelOperator does.
const KeyRules := preload("res://autoloads/key_rules.gd")
func begin_circle_select() -> void:
_circ_sel = true
_circ_painting = false
var vp := get_viewport()
if vp != null:
_circ_pos = vp.get_mouse_position()
_circ_show()
print("[MapDCLevel] circle select: LMB paints in, Shift+LMB paints out, wheel / [ ] resize, RMB / Esc / Enter ends")
func is_circle_selecting() -> bool:
return _circ_sel
func end_circle_select() -> void:
_circ_sel = false
_circ_painting = false
_circ_show()
func _circ_show() -> void:
if _circ_layer == null:
_circ_layer = CanvasLayer.new()
_circ_layer.layer = 90
add_child(_circ_layer)
_circ_ui = Control.new()
_circ_ui.mouse_filter = Control.MOUSE_FILTER_IGNORE
_circ_ui.set_anchors_preset(Control.PRESET_FULL_RECT)
_circ_layer.add_child(_circ_ui)
_circ_ui.draw.connect(_circ_draw)
_circ_ui.visible = _circ_sel
_circ_ui.queue_redraw()
func _circ_draw() -> void:
if not _circ_sel:
return
var col := Color(1.0, 0.45, 0.35, 0.9) if _circ_sub else Color(1.0, 0.7, 0.2, 0.9)
_circ_ui.draw_arc(_circ_pos, _circ_radius, 0.0, TAU, 64, col, 1.5, true)
## Handles circle mode's mouse. True when it took the event.
func _circle_select_input(event: InputEvent) -> bool:
if not _circ_sel:
return false
if event is InputEventMouseMotion:
_circ_pos = (event as InputEventMouseMotion).position
if _circ_painting:
circle_select_at(_circ_pos, _circ_radius, _circ_sub)
_circ_show()
return true
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
_circ_pos = mb.position
match mb.button_index:
MOUSE_BUTTON_RIGHT:
if mb.pressed:
end_circle_select()
return true
MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN:
if mb.pressed:
_circ_radius = clampf(_circ_radius * (1.15 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.15),
CIRC_MIN, CIRC_MAX)
_circ_show()
return true
MOUSE_BUTTON_LEFT:
_circ_painting = mb.pressed
_circ_sub = mb.shift_pressed
if mb.pressed:
circle_select_at(_circ_pos, _circ_radius, _circ_sub)
_circ_show()
return true
return false
## Paint the live brushes whose centre projects within `radius` of `at` (screen pixels)
## into the selection, or out of it when `sub`. Returns the selection size.
func circle_select_at(at: Vector2, radius: float, sub: bool) -> int:
var cam := get_viewport().get_camera_3d()
if cam == null or level == null:
return 0
var out := PackedInt32Array(_sel_brushes)
var xf: Transform3D = (level as Node3D).global_transform
var changed := false
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
var d: Dictionary = level.get_brush(id)
if not d.has("aabb_min") or bool(d.get("clearance", false)):
continue
var c: Vector3 = xf * (((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
if cam.is_position_behind(c) or cam.unproject_position(c).distance_to(at) > radius:
continue
var i := out.find(id)
if sub and i >= 0:
out.remove_at(i)
changed = true
elif not sub and i < 0:
out.append(id)
changed = true
if changed:
select_brushes(out)
return out.size()
## Select the live brushes whose centre projects into `r` (screen pixels).
func box_select(r: Rect2, add: bool) -> int:
var cam := get_viewport().get_camera_3d()
if cam == null or level == null:
return 0
var out := PackedInt32Array(_sel_brushes) if add else PackedInt32Array()
var xf: Transform3D = (level as Node3D).global_transform
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
var d: Dictionary = level.get_brush(id)
if not d.has("aabb_min") or bool(d.get("clearance", false)) or out.has(id):
continue
var c: Vector3 = xf * (((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
if cam.is_position_behind(c):
continue
if r.has_point(cam.unproject_position(c)):
out.append(id)
select_brushes(out)
print("[MapDCLevel] box select: %d brush(es)" % out.size())
return out.size()
func _q_make_real() -> void:
if _sel_asset != "":
make_real_asset(_sel_asset) # a placed asset (#120 L4 §15.3)
return
var s := stack_subject()
if s != "":
make_real(s)
## E ON A SHAPE PIECE (#109, user 2026-10-13, option A): the pieces are read-only — the
## fold regenerates them from the shape's params, so a pushed face is an edit the
## generator cannot reproduce. Extrude asks instead of acting: Make Real, then extrude
## the face still under the aim; or Cancel, which writes nothing.
var _pending_extrude_shape := ""
func prompt_shape_extrude(dot: String) -> void:
_pending_extrude_shape = dot
if _q_menu == null:
return
var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_q_menu.open_sections([["This is a shape: its faces are read-only",
[["builder.extrude_make_real"], ["builder.prompt_cancel"]]]], at)
print("[MapDCLevel] extrude: %s is a parametric shape — Make Real, or Cancel" % dot)
func _q_extrude_make_real() -> void:
var dot := _pending_extrude_shape
_pending_extrude_shape = ""
if dot == "" or make_real(dot) <= 0:
return
# The aim has not moved: the face under it is now a plain brush's.
if _ensure_ops().activate(_extrude):
print("[MapDCLevel] extrude: [ ] to push the face, Enter to extrude, Esc to cancel")
func _q_prompt_cancel() -> void:
if _pending_extrude_shape != "":
print("[MapDCLevel] extrude cancelled — %s is unchanged" % _pending_extrude_shape)
_pending_extrude_shape = ""
if _pending_part_shape != "":
print("[MapDCLevel] edit cancelled — %s is unchanged" % _pending_part_shape)
_pending_part_shape = ""
_pending_part_edit = Callable()
## A SHAPE'S PARTS ARE SELECTABLE, READ-ONLY UNTIL MADE REAL (#109, user 2026-10-13:
## "select, prompt on edit"). Every edit of a vertex / edge / face (G, R, the [ ] nudge,
## the knife) asks here first: on a plain brush it goes ahead; on a shape's piece it asks
## "Make Real, then go on / Cancel" and remembers what was asked for.
var _pending_part_shape := ""
var _pending_part_edit := Callable()
## True: go ahead. False: `brush` (default the selected one) is a shape's piece — the
## prompt is open, and Make Real will run `resume` on the same part of the plain brush.
func guard_shape_part(what: String, resume: Callable, brush: int = -2) -> bool:
var b := _sel_brush if brush == -2 else brush
# A PLACED ASSET's part (#120 L4): read-only too. Make Real first (no "then go on":
# its parts come back under new names in a new collection), or Cancel.
var pa := placement_of_brush(b) if b >= 0 else ""
if pa != "":
if _q_menu != null:
var pat := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_q_menu.open_sections([["This is a placed asset: its parts are read-only",
[["builder.make_real"], ["builder.prompt_cancel"]]]], pat)
_sel_asset = pa
print("[MapDCLevel] %s: %s is a placed asset — Make Real it first, or Cancel" % [what, pa])
return false
var sd := shape_of_brush(b) if b >= 0 else ""
if sd == "":
return true
_pending_part_shape = sd
_pending_part_edit = resume
if _q_menu != null:
var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_q_menu.open_sections([["This is a shape: its parts are read-only",
[["builder.make_real_then"], ["builder.prompt_cancel"]]]], at)
print("[MapDCLevel] %s: %s is a parametric shape — Make Real, then go on; or Cancel" % [what, sd])
return false
## MAKE REAL, THEN GO ON: the shape's pieces become plain brushes (new ids, new plane
## dots, the planes in the SAME order), so the selected part is carried across by PLANE
## INDEX onto the brush its piece became — points and edge ends are world-space and the
## geometry is unchanged — and then the edit that was asked for runs.
func _q_make_real_then() -> void:
var dot := _pending_part_shape
var resume := _pending_part_edit
_pending_part_shape = ""
_pending_part_edit = Callable()
if dot == "":
return
var kind := _sel_kind
var point := _sel_point
var ea := _sel_edge_a
var eb := _sel_edge_b
var old := _sel_brush
var idx: Array[int] = []
for k: String in _sel_keys:
idx.append(level.face_by_key(old, k))
var faces: Array = [] # the cross-brush face set: [key, old id, plane index]
for fk: Dictionary in _sel_faces:
var ob: int = level.brush_by_key(String(fk["b"]))
faces.append([String(fk["b"]), ob, level.face_by_key(ob, String(fk["p"])) if ob >= 0 else -1])
var edges: Array = [] # the edge set (§14.4): [key, old id, plane index, plane index]
for ek: Dictionary in _sel_edges:
var oe: int = level.brush_by_key(String(ek["b"]))
var pk: Array = ek["p"]
edges.append([String(ek["b"]), oe, level.face_by_key(oe, String(pk[0])) if oe >= 0 else -1,
level.face_by_key(oe, String(pk[1])) if oe >= 0 else -1])
if make_real(dot) <= 0:
return
var nb: int = level.brush_by_key(String(last_real.get(old, ""))) if last_real.has(old) else -1
if nb >= 0 and kind != "brush":
var keys: PackedStringArray = level.get_brush(nb).get("plane_keys", PackedStringArray())
var sel := PackedStringArray()
for i: int in idx:
if i >= 0 and i < keys.size():
sel.append(keys[i])
_sel_brushes.clear()
_sel_brushes.append(nb)
_sel_brush = nb
_sel_kind = kind
_sel_keys = sel
_sel_point = point
_sel_edge_a = ea
_sel_edge_b = eb
_sel_faces.clear()
for f: Array in faces:
# A piece of the shape: its new brush. Any other brush: unchanged.
var key := String(last_real[int(f[1])]) if last_real.has(int(f[1])) else String(f[0])
var b2: int = level.brush_by_key(key)
var k2: PackedStringArray = level.get_brush(b2).get("plane_keys", PackedStringArray()) if b2 >= 0 else PackedStringArray()
if b2 >= 0 and int(f[2]) >= 0 and int(f[2]) < k2.size():
_sel_faces.append({"b": key, "p": k2[int(f[2])]})
_sel_edges.clear()
for e: Array in edges:
var ekey := String(last_real[int(e[1])]) if last_real.has(int(e[1])) else String(e[0])
var b3: int = level.brush_by_key(ekey)
var k3: PackedStringArray = level.get_brush(b3).get("plane_keys", PackedStringArray()) if b3 >= 0 else PackedStringArray()
if b3 >= 0 and int(e[2]) >= 0 and int(e[3]) >= 0 and int(e[2]) < k3.size() and int(e[3]) < k3.size():
var pr: Array = [String(k3[int(e[2])]), String(k3[int(e[3])])]
pr.sort()
_sel_edges.append({"b": ekey, "p": pr})
_update_face_highlight()
_update_element_highlight()
if resume.is_valid():
resume.call()
## Clear the cuts: those MADE WITH the subject when it is a cutter, else those ON it.
## One gesture; Ctrl+Z brings them all back.
func clear_cuts(subject: String = "") -> int:
if subject == "":
subject = stack_subject()
var ops: Array = cuts_by(subject)
var made_with := not ops.is_empty()
if ops.is_empty():
ops = op_stack(subject)
var seen := {}
_persist.begin_gesture("Clear cuts")
for o: Dictionary in ops:
if not seen.has(o["dot"]):
seen[o["dot"]] = true
_persist.record("op.remove", {"target": o["dot"]})
_persist.end_gesture()
if not seen.is_empty():
_persist.refold()
_refresh_brush_view()
print("[csg] cleared %d cut(s) %s %s" % [seen.size(), "made with" if made_with else "on", subject])
return seen.size()
## A cutter's ops all become `kind` — one gesture.
func set_cutter_kind(cutter: String, kind: String) -> int:
var seen := {}
_persist.begin_gesture("Cut: %s" % kind)
for o: Dictionary in cuts_by(cutter):
if not seen.has(o["dot"]) and String(o["kind"]) != kind:
seen[o["dot"]] = true
_persist.record("op.set", {"target": o["dot"], "op": kind})
_persist.end_gesture()
if not seen.is_empty():
_persist.refold()
_refresh_brush_view()
return seen.size()
## Go from a cutter to what it cuts (the first target).
func select_cut_target(cutter: String = "") -> void:
if cutter == "":
cutter = stack_subject()
var cuts: Array = cuts_by(cutter)
if cuts.is_empty():
return
var t := String(cuts[0]["target"])
if (_persist.live_shapes() as Dictionary).has(t):
select_shape(t)
else:
select_brushes(level.get_group_brush_ids(t))
## H / Shift+H / Alt+H — through the Outliner's rows, so its eye shows the same state.
## OBJECTS too (#120 F1, outliner audit bug 7: it returned on an empty brush selection),
## by their rows, so the eye shows it.
func hide_selection() -> int:
if _outliner == null or (_sel_brushes.is_empty() and _sel_objects.is_empty() and _sel_curves.is_empty()):
return 0
# THE UNION (§17.10): every kind held is hidden, by its rows.
var n := 0
if not _sel_curves.is_empty():
n += int(_outliner.hide_curves(selected_curves()))
if not _sel_objects.is_empty():
n += int(_outliner.hide_objects(selected_objects()))
if not _sel_brushes.is_empty():
n += int(_outliner.hide_brushes(Array(_sel_brushes)))
_clear_face_selection() # Blender deselects what it hides
_refresh_brush_view()
print("[MapDCLevel] hid %d row(s) — Alt+H shows everything again" % n)
return n
func hide_unselected() -> int:
if _outliner == null or (_sel_brushes.is_empty() and _sel_objects.is_empty()):
return 0
var n: int = _outliner.hide_others(Array(_sel_brushes), selected_objects())
_refresh_brush_view()
print("[MapDCLevel] hid %d other row(s) — Alt+H shows everything again" % n)
return n
func unhide_all() -> int:
if _outliner == null:
return 0
var n: int = _outliner.unhide_all()
_refresh_brush_view()
print("[MapDCLevel] unhid %d row(s)" % n)
return n
## Any movement key held OTHER than move_back (the S of a Shift+S press is move_back).
func _moving_except_back() -> bool:
for a: String in ["move_forward", "move_left", "move_right", "jump", "crouch"]:
if InputMap.has_action(a) and Input.is_action_pressed(a):
return true
return false
## For the status bar's right side: what only the map knows.
func _status_info() -> Dictionary:
return {"grid": "%s · snap %s" % [fmt_units(base_grid_units()), "on" if snap_enabled else "off"],
"sel": _sel_brushes.size()}
## The grid dropdown's entry point (keys use - / = in the input handler).
func set_grid_index(i: int) -> void:
_grid_idx = clampi(i, 0, GRID_UNITS.size() - 1)
print("[MapDCLevel] grid %s (%.3f m)" % [fmt_units(base_grid_units()), base_grid_units() * WORLD_SCALE])
func set_current_tex(id: String) -> void:
current_tex = id
if _tex_strip != null and is_instance_valid(_tex_strip):
_tex_strip.refresh_current()
## Only on the pack-textured empty level: on the .MAP import path faces index
## START.MAP's own table, and a pack id written there would draw the missing layer.
func _register_texture_strip() -> void:
var dock := get_node_or_null("/root/BuildDock")
if dock == null:
return
var strip := VBoxContainer.new()
strip.set_script(load("res://scenes/game/texture_strip.gd"))
strip.setup(self)
dock.register("textures", "Textures", strip, true, dock.SIDE_BOTTOM, dock.WS_LEVEL)
_tex_strip = strip
# UV controls for the selection (docs/uv-controls-design.md §4) — same maps as the strip.
var face := VBoxContainer.new()
face.set_script(load("res://scenes/game/face_panel.gd"))
face.setup(self)
dock.register("face", "Face", face, true, dock.SIDE_RIGHT, dock.WS_LEVEL)
_face_panel = face
## U — the CURRENT texture onto the selection. A face (or edge / vertex) selection
## textures the selected faces of the active brush; a brush selection textures every
## face of every selected brush.
##
## ONLY THE TEXTURE CHANGES (mask TEX): offsets, scale, rotation and a lock-frozen
## explicit map are kept, so retexturing an aligned wall does not misalign it. A
## face that had NO texture also becomes paraxial (mask TEX|MODE), or it would
## carry an id and still draw nothing. One face.tex record per brush per mask —
## applied locally first through DCLevel.set_face_tex, which merges exactly as the
## fold does, because the author never refolds its own echo.
func apply_texture_to_selection() -> int:
if _sel_brushes.is_empty():
print("[texture] nothing selected — click a brush or face first (B / left click)")
return 0
# A PARAMETRIC SHAPE is textured as a whole (§13.3): one shape.set {tex}. A face.tex
# on a piece would be lost — every regeneration names the pieces afresh.
if _sel_asset != "" or placement_of_brush(_sel_brush) != "":
print("[texture] a placed asset's faces are read-only (#120 L4) — Make Real it to texture them")
return 0
if _sel_shape != "":
return set_shape_tex(_sel_shape, current_face_tex())
var tex_id: String = current_face_tex()["tex"]
var done := 0
for t: Array in _texture_targets():
var b: int = t[0]
var keys: PackedStringArray = t[1]
var fts: Array = level.get_brush(b).get("face_tex", [])
var textured: Array = []
var bare: Array = []
for k: String in keys:
var f: int = level.face_by_key(b, k)
if f < 0:
continue
var mode: int = int((fts[f] as Dictionary).get("mode", 0)) if f < fts.size() else 0
(bare if mode == 0 else textured).append(k)
var bkey: String = level.get_brush_key(b)
for job: Array in [[textured, 1, {"tex": tex_id}], [bare, 3, {"tex": tex_id, "mode": 1}]]:
var ks: Array = job[0]
if ks.is_empty():
continue
for k: String in ks:
level.set_face_tex(b, level.face_by_key(b, k), job[2], job[1])
_persist.record("face.tex", {"brush_key": bkey, "face_keys": ks,
"tex": job[2], "mask": job[1]})
done += ks.size()
if done > 0:
level.rebuild()
_note_recent_texture(tex_id)
print("[texture] %s on %d face(s)" % [tex_id, done])
return done
## [[brush id, face keys], ...] that texture ops act on: the selected faces of the
## active brush (face / edge / vertex selection), or every face of every selected brush.
func _texture_targets() -> Array:
var targets: Array = []
if _sel_kind != "brush" and _sel_brush >= 0 and not _sel_keys.is_empty():
targets.append([_sel_brush, _sel_keys])
else:
for b: int in _sel_brushes:
targets.append([b, level.get_brush(b).get("plane_keys", PackedStringArray())])
return targets
## face.tex field masks — libdc DCTexMask.
const TEXMASK_MODE := 2
const TEXMASK_OFFSET := 4
const TEXMASK_ROT := 8
const TEXMASK_SCALE := 16
const TEXMASK_EXPLICIT := 32
const TEXMASK_P2 := 64 # rect, seed, hflags (hotspot)
## Which fields each alignment op writes on a rule-mode face. On an EXPLICIT face
## every op writes the explicit axes instead (mask EXPLICIT).
const _UV_OP_MASK := {"fit": TEXMASK_SCALE, "justify": TEXMASK_OFFSET, "align": TEXMASK_ROT,
"nudge": TEXMASK_OFFSET | TEXMASK_ROT | TEXMASK_SCALE,
"nudge_screen": TEXMASK_OFFSET, "justify_screen": TEXMASK_OFFSET}
## The camera's screen axes in world space, for the camera-relative ops. No camera
## (a headless gate) -> world X / Y, which is what a camera looking down -Z sees.
func camera_axes() -> Dictionary:
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return {"right": Vector3.RIGHT, "up": Vector3.UP}
var b := cam.global_transform.basis
return {"right": b.x.normalized(), "up": b.y.normalized()}
## UV CONTROLS (docs/uv-controls-design.md) on the selection — same targets as U.
## fit {axis, policy, trim} · justify {axis, sign, policy} · align {policy}
## nudge {du, dv (texture metres), drot (deg), mul_u, mul_v} · mode {mode 1|2|3}
## reset {}
## The maths is libdc's (DCLevel.face_tex_compute, author-side only: it reads the
## face polygon and the pack's tile size). Each face gets the op's fields ONLY (the
## record mask), applied locally first like U-apply, then ONE face.tex record per
## brush per distinct result. Untextured faces are skipped (apply a texture first).
## Returns the number of faces changed.
func uv_op(op: String, args: Dictionary = {}) -> int:
if _sel_brushes.is_empty() or level == null:
return 0
var done := 0
var shape_dots := {}
for t: Array in _texture_targets():
var b: int = t[0]
# HOTSPOT ON A LIVE SHAPE goes on the SHAPE (its tex), not on its pieces: a piece is
# named "<author>/<i>@<ver>:<seq>" and every shape edit renames it, so a face.tex
# record on one was dropped by the next edit (user 2026-10-12: "fix hotspot on
# shapes so it survives edits"). The fold copies the shape's tex to every solid face.
if op.begins_with("hotspot") and shape_of_brush(b) != "":
shape_dots[shape_of_brush(b)] = true
continue
var fts: Array = level.get_brush(b).get("face_tex", [])
var groups := {} # "mask|value" -> [mask, masked tex, face keys]
for k: String in (t[1] as PackedStringArray):
var f: int = level.face_by_key(b, k)
if f < 0 or f >= fts.size():
continue
var cur: Dictionary = fts[f]
var mode := int(cur.get("mode", 0))
if mode == 0 or String(cur.get("tex", "")) == "":
continue
var res := {}
var mask := 0
if op == "reset":
res = {"mode": 1, "off_u": 0.0, "off_v": 0.0, "rot": 0.0, "scale_u": 1.0, "scale_v": 1.0}
mask = TEXMASK_MODE | TEXMASK_OFFSET | TEXMASK_ROT | TEXMASK_SCALE
elif op == "hotspot":
# Fit to the best rect of the face's trim sheet (auto rect, seed kept). Only
# textures with rects can — anything else would draw the missing layer.
if not (TexturePacks.entry(String(cur["tex"])).get("rects") is Array):
continue
res = {"mode": 4, "rect": -1, "seed": int(cur.get("seed", 0)), "hflags": 0}
mask = TEXMASK_MODE | TEXMASK_P2
elif op == "hotspot_reroll":
if mode != 4:
continue
# The rect rides along UNCHANGED: P2 writes rect + seed + hflags together, and
# writing -1 here silently unpinned a pinned face.
res = {"rect": int(cur.get("rect", -1)), "seed": int(cur.get("seed", 0)) + 1,
"hflags": int(cur.get("hflags", 0))}
mask = TEXMASK_P2
elif op == "hotspot_pin":
if mode != 4:
continue
res = level.face_tex_compute(b, f, "hotspot_pin", {})
mask = TEXMASK_P2
elif op == "mode":
var m := int(args.get("mode", 1))
if m == mode:
continue
if m == 2: # -> explicit: freeze the current look
res = level.face_tex_compute(b, f, "freeze", {})
mask = TEXMASK_MODE | TEXMASK_EXPLICIT
else: # -> a rule: its stored offset / rot / scale apply again
res = {"mode": m}
mask = TEXMASK_MODE
else:
if not _UV_OP_MASK.has(op):
return done
var a := args.duplicate()
var e: Dictionary = TexturePacks.entry(String(cur["tex"]))
a["tile_u"] = float(e.get("tile_u_m", 1.0))
a["tile_v"] = float(e.get("tile_v_m", 1.0))
res = level.face_tex_compute(b, f, op, a)
mask = TEXMASK_EXPLICIT if mode == 2 else int(_UV_OP_MASK[op])
if res.is_empty():
continue
var masked := _masked_tex(res, mask)
level.set_face_tex(b, f, masked, mask)
var gk := "%d|%s" % [mask, var_to_str(masked)]
if not groups.has(gk):
groups[gk] = [mask, masked, []]
(groups[gk][2] as Array).append(k)
done += 1
var bkey: String = level.get_brush_key(b)
for g: Array in groups.values():
_persist.record("face.tex", {"brush_key": bkey, "face_keys": g[2], "tex": g[1], "mask": g[0]})
if done > 0:
level.rebuild()
if not shape_dots.is_empty():
done += _shape_hotspot(op, shape_dots.keys())
print("[texture] %s on %d face(s)" % [op, done])
return done
## Hotspot on whole parametric shapes: ONE shape.set {tex} each (mode 4, auto rect), so it
## survives every later edit of the shape. Re-roll = the shape's seed + 1 (each face still
## breaks its own near-ties from seed + its face hash, which ignores the piece's @<ver> —
## dc_face_hash). Pin is per-face and a shape has ONE tex, so pinning would force every
## face onto one rect: refused, with the way to do it. Returns the solid faces covered.
func _shape_hotspot(op: String, dots: Array) -> int:
var n := 0
var wrote := false
for d: Variant in dots:
var dot := String(d)
var tex: Dictionary = ((_persist.shape_state(dot) as Dictionary).get("tex", {}) as Dictionary).duplicate()
var id := String(tex.get("tex", ""))
if id == "" or not (TexturePacks.entry(id).get("rects") is Array):
print("[texture] shape %s has no trim-sheet texture — apply one (e.g. composed/industrial) first" % dot)
continue
match op:
"hotspot":
tex["mode"] = 4
tex["rect"] = -1
tex["seed"] = int(tex.get("seed", 0))
tex["hflags"] = 0
"hotspot_reroll":
if int(tex.get("mode", 0)) != 4:
continue
tex["seed"] = int(tex.get("seed", 0)) + 1
_:
print("[texture] Pin on a whole shape would put every face on ONE rect — Make Real, then pin faces")
continue
if _persist.record("shape.set", {"target": dot, "params": {}, "tex": tex}) != "":
wrote = true
if wrote:
_persist.refold()
_reselect_shape()
for d2: Variant in dots:
for b: int in (level.get_group_brush_ids(String(d2)) as PackedInt32Array):
var bd: Dictionary = level.get_brush(b)
if not bool(bd.get("clearance", false)) and not bool(bd.get("cutter", false)):
n += (bd.get("planes", PackedFloat32Array()) as PackedFloat32Array).size() / 4
return n
## The `face_sel` keys (keymap.gd). True when the event was one of them.
func _uv_key(event: InputEvent) -> bool:
var step: float = _grid_step() # one EFFECTIVE grid step, metres (user: tied to the grid)
# ARROWS ARE CAMERA-RELATIVE (TrenchBroom): the texture moves the way the key points
# on screen, whatever the face's u / v happen to be.
var arrows := {"builder.uv_left": [-1, 0], "builder.uv_right": [1, 0],
"builder.uv_up": [0, 1], "builder.uv_down": [0, -1]}
for action: String in arrows:
if Keymap.tapped(event, action):
var a := camera_axes()
a["dx"] = arrows[action][0]
a["dy"] = arrows[action][1]
a["step"] = step
uv_op("nudge_screen", a)
_last_uv_key = action
return true
for pair: Array in [["builder.uv_hotspot", "hotspot"], ["builder.uv_reroll", "hotspot_reroll"],
["builder.uv_pin", "hotspot_pin"]]:
if Keymap.tapped(event, String(pair[0])):
uv_op(String(pair[1]))
_last_uv_key = String(pair[0])
return true
var nudges := {
"builder.uv_rot_ccw": {"drot": 15.0}, "builder.uv_rot_cw": {"drot": -15.0},
"builder.uv_rot_ccw_fine": {"drot": 1.0}, "builder.uv_rot_cw_fine": {"drot": -1.0},
}
for action: String in nudges:
if Keymap.tapped(event, action):
uv_op("nudge", nudges[action])
_last_uv_key = action
return true
# ALWAYS NEXT (Shift: PREV), as TrenchBroom's buttons do. Stateless: libdc only
# advances when the face already sits exactly on a candidate, so the first press
# on an unfitted / unaligned face still lands on the best one.
for pair: Array in [["builder.uv_fit", "fit", 1], ["builder.uv_fit_prev", "fit", 2],
["builder.uv_align", "align", 1], ["builder.uv_align_prev", "align", 2]]:
if not Keymap.tapped(event, String(pair[0])):
continue
if String(pair[1]) == "fit":
uv_op("fit", {"axis": 0, "policy": pair[2]})
uv_op("fit", {"axis": 1, "policy": pair[2]})
else:
uv_op("align", {"policy": pair[2]})
_last_uv_key = String(pair[0])
return true
return false
## Only the fields a mask covers — records stay small and say exactly what changed.
static func _masked_tex(t: Dictionary, mask: int) -> Dictionary:
var out := {}
var keys: Array = []
if mask & TEXMASK_MODE:
keys.append("mode")
if mask & TEXMASK_OFFSET:
keys.append_array(["off_u", "off_v"])
if mask & TEXMASK_ROT:
keys.append("rot")
if mask & TEXMASK_SCALE:
keys.append_array(["scale_u", "scale_v"])
if mask & TEXMASK_EXPLICIT:
keys.append_array(["U", "V", "u0", "v0"])
if mask & TEXMASK_P2:
keys.append_array(["rect", "seed", "hflags"])
for k: String in keys:
if t.has(k):
out[k] = t[k]
return out
## Y — make the aimed face's texture the current one. `hit` is for gates; in play it
## is the crosshair (or the pointer in cursor mode).
func eyedrop_texture(hit: Dictionary = {}) -> String:
if hit.is_empty():
hit = _crosshair_hit()
if hit.is_empty():
return ""
var b: int = _brush_from_hit(hit)
if b < 0:
return ""
var f: int = level.pick_face(b, hit["position"])
var fts: Array = level.get_brush(b).get("face_tex", [])
if f < 0 or f >= fts.size():
return ""
var id := String((fts[f] as Dictionary).get("tex", ""))
if id == "":
print("[texture] that face has no texture")
return ""
set_current_tex(id)
print("[texture] picked %s" % id)
return id
func _note_recent_texture(id: String) -> void:
if _tex_strip != null and is_instance_valid(_tex_strip):
_tex_strip.refresh_current()
func _on_texture_setting(key: String, _value: Variant) -> void:
if key == "texture_res" and _packs != null:
_packs.set_res(int(Settings.resolved_value("texture_res")))
elif key == "surface_detail" and _packs != null:
_packs.set_detail(int(Settings.resolved_value("surface_detail")))
## The three files load_pbr actually reads per material, lowercased.
##
## ⛔ NOT _norm.tga. wad_loader.gd:171 GENERATES the normal from _bump with
## DarkPlaces' math on purpose — hemebond's baked _norm.tga uses Z=1 and gives
## near-sideways normals (median ~88 deg) where DP's Z=382 gives gentle relief.
## Publishing the 343 _norm.tga files in build_out would ship bytes nothing reads.
const PBR_SUFFIXES: Array[String] = ["_gloss.jpg", "_glow.jpg", "_bump.jpg"]
## Where fetched PBR files are materialised under the names load_pbr expects.
const PBR_LOCAL_DIR := "user://assets/pbr"
## Retained ONLY so a late-arriving WAD can re-texture. Nothing else reads it;
## if the textures are already local at import time this stays unused.
var _importer_for_retexture: QuakeMapImporter = null
var _retexture_hooked := false
var _retexture_pending := false
## How long to gather asset_ready arrivals before one rebuild. Long enough that a
## burst of PBR files coalesces, short enough to be invisible on a join.
const RETEXTURE_DEBOUNCE_S := 0.75
## The WAD and palette, resolved through the room rather than assumed local.
##
## Returns "" for either when the bytes are not here YET — not an error and not
## permanent: _hook_retexture() re-runs the material once they land.
func _wad_paths() -> Array:
return [asset_path("wad", "base.wad", WAD_PATH),
asset_path("palette", "palette.lmp", PAL_PATH)]
## Re-apply the material when a declared asset arrives.
##
## ⛔ RE-RUNS _apply_textures AND NOTHING ELSE. _spawn_sky_area_lights SPAWNS
## NODES; calling it twice is how the duplicate spawners in hyper-games #9 item 9
## happened. _apply_textures is safe to repeat because it builds a fresh
## ShaderMaterial and ends at `mi.material_override = mat` — it adds nothing to
## the tree.
func _hook_retexture() -> void:
if _retexture_hooked:
return
var sync := get_node_or_null(^"/root/AssetSync")
if sync == null or not sync.has_signal("asset_ready"):
return
sync.asset_ready.connect(_on_asset_ready)
_retexture_hooked = true
func _on_asset_ready(kind: String, _name: String, _path: String) -> void:
if kind != "wad" and kind != "palette" and kind != "pbr":
return
if _importer_for_retexture == null:
return
var wp := _wad_paths()
if String(wp[0]) == "" or String(wp[1]) == "":
return # the other half has not landed yet
_request_retexture()
## ⛔ COALESCED, and it has to be. A map with 66 materials expects ~200 PBR files,
## each arriving as its own asset_ready — and _apply_textures rebuilds the WAD
## atlas, the projection table and BOTH PBR atlases every call. Re-applying per
## arrival would be ~200 full atlas rebuilds during a join.
##
## The timer is a COALESCING window, not a poll: nothing here asks the network
## anything. Arrivals are still the trigger; this only decides how many of them
## share one rebuild.
func _request_retexture() -> void:
if _retexture_pending:
return
_retexture_pending = true
var t := get_tree().create_timer(RETEXTURE_DEBOUNCE_S)
t.timeout.connect(_do_retexture)
func _do_retexture() -> void:
_retexture_pending = false
if _importer_for_retexture == null:
return
print("[MapDCLevel] assets arrived over the mesh — re-applying textures")
_apply_textures(_importer_for_retexture)
## The PBR files THIS level uses — bounded by the materials the map references,
## not the 1509 entries in build_out.
##
## ⚠ NOT expressible as required_assets(): that runs at _ready, and
## material_names only exists after the import. This is the third case the
## catalogue has to serve — a fixed set that is only KNOWABLE post-import.
func _pbr_expected(names: PackedStringArray) -> PackedStringArray:
var out := PackedStringArray()
for raw in names:
var n := String(raw).to_lower()
for suf in PBR_SUFFIXES:
out.append(n + suf)
return out
## Offer the PBR files this machine holds. Deferred and timed rather than assumed
## cheap: this is ~3 hashes per material, and a map with 66 materials is ~200
## streaming reads at load. If that ever shows up in a frame graph the answer is
## to publish on a worker, not to skip it.
func _publish_pbr(names: PackedStringArray) -> void:
var sync := get_node_or_null(^"/root/AssetSync")
if sync == null or not sync.is_ready():
return
if not DirAccess.dir_exists_absolute(TEX_BUILD_DIR):
return
var t0 := Time.get_ticks_msec()
var n := 0
for fname in _pbr_expected(names):
var src := TEX_BUILD_DIR.path_join(fname)
if FileAccess.file_exists(src):
if String(sync.publish_file("pbr", fname, src)) != "":
n += 1
print("[MapDCLevel] published %d PBR files in %d ms" % [n, Time.get_ticks_msec() - t0])
## A directory containing the PBR files under the names load_pbr expects, or "".
##
## Prefers the authoring tree untouched. Otherwise MATERIALISES one under
## user:// from whatever the room has: load_pbr takes a DIRECTORY and does
## dir.path_join(name + "_gloss.jpg"), so content-addressed
## user://content/<oid>.jpg cannot be handed to it directly — the expected
## filenames have to exist somewhere.
##
## A partial directory is USEFUL, not a failure: load_pbr already substitutes a
## flat colour per missing map and reports COVERAGE rather than success, so a
## level with half its PBR fetched renders half-detailed instead of albedo-only.
func _pbr_dir(names: PackedStringArray) -> String:
if DirAccess.dir_exists_absolute(TEX_BUILD_DIR):
return TEX_BUILD_DIR
var sync := get_node_or_null(^"/root/AssetSync")
if sync == null or not sync.is_ready():
return ""
var dst_abs := ProjectSettings.globalize_path(PBR_LOCAL_DIR)
DirAccess.make_dir_recursive_absolute(dst_abs)
var got := 0
for fname in _pbr_expected(names):
var target := dst_abs.path_join(fname)
if FileAccess.file_exists(target):
got += 1
continue
var have := String(sync.ensure("pbr", fname))
if have == "":
continue
# COPY, not rename: the content-addressed original must stay put so this
# peer keeps serving it. These are `size`-resized jpgs, a few KB each.
if DirAccess.copy_absolute(ProjectSettings.globalize_path(have), target) == OK:
got += 1
if got == 0:
return ""
print("[MapDCLevel] PBR from the room: %d/%d files" % [got, _pbr_expected(names).size()])
return dst_abs
## The external files this level needs, declared for map_core to publish.
##
## Every one of these is an absolute path into the AUTHORING machine's
## /storage/projects/gamedev/quake/ tree, and every consumer of them is guarded
## by a file_exists()/dir_exists() check that degrades SILENTLY — no atlas, no
## PBR, no rtlights, and the level renders bare on any machine that is not this
## one. Declaring them here is what lets every peer fetch them instead.
##
## ⚠ TEX_BUILD_DIR is a DIRECTORY, not a file, so it is NOT listed: publishing it
## needs per-file enumeration of the build output and belongs in its own change.
## Until then the PBR path still degrades to albedo-only off-machine.
func required_assets() -> Array:
return [
{"kind": "map", "name": "START.MAP", "path": MAP_PATH},
{"kind": "wad", "name": "base.wad", "path": WAD_PATH},
{"kind": "palette", "name": "palette.lmp", "path": PAL_PATH},
{"kind": "rtlights", "name": "start.rtlights", "path": RTLIGHTS_PATH},
]
## Offer our copy to the room. Cheap and idempotent: hashing is a streaming read
## (no whole-file buffer), and serving hands out a locator, not bytes — the file
## is read per chunk, at offset, only when a peer actually asks.
func _announce_source(path: String) -> void:
if _persist == null or not ContentOverlay or not ContentOverlay.is_ready():
return
var h: String = ContentOverlay.publish(path)
if h == "":
push_warning("[MapDCLevel] could not publish %s to the overlay" % path)
return
var size: int = FileAccess.open(path, FileAccess.READ).get_length()
_persist.persist_source(h, path.get_file(), size)
print("[MapDCLevel] serving level source %s (%s, %d bytes) to the room"
% [path.get_file(), h.left(12), size])
## No local .MAP: fetch the one this room announced.
##
## The durable record may not have loaded yet, and no peer may have announced one
## at all, so this retries off GameState.state_changed rather than failing once at
## _ready. A fetch with no holder attached is NOT an error either — it stays open
## and is re-sent to every peer that joins later.
func _request_source() -> void:
if not ContentOverlay or not ContentOverlay.is_ready() or _persist == null:
push_warning("[MapDCLevel] %s not found and no overlay — placeholder floor kept" % _map_path())
return
if not GameState.state_changed.is_connected(_on_state_for_source):
GameState.state_changed.connect(_on_state_for_source)
if not ContentOverlay.content_ready.is_connected(_on_source_arrived):
ContentOverlay.content_ready.connect(_on_source_arrived)
_try_request_source()
func _try_request_source() -> void:
if _source_hash != "":
return
var src: Dictionary = _persist.find_source()
if src.is_empty():
return
_source_hash = String(src.get("hash", ""))
var dest: String = ContentOverlay.request(_source_hash, String(src.get("name", "level.map")))
if dest == "":
_source_hash = ""
return
print("[MapDCLevel] no local .MAP — fetching %s (%s, %d bytes) over the mesh"
% [String(src.get("name", "?")), _source_hash.left(12), int(src.get("size", 0))])
func _on_state_for_source(key: String, _v: Variant, _c: int) -> void:
if _source_hash == "" and key.begins_with("world:level:") and key.ends_with(":source"):
_try_request_source()
func _on_source_arrived(content_hash: String, path: String) -> void:
if content_hash != _source_hash:
return
print("[MapDCLevel] level source arrived: %s" % path)
_import_from(ProjectSettings.globalize_path(path))
## The import transform, kept because the grid snap needs it. world = (quake -
## map_center) * WORLD_SCALE + aligned_offset, and map_center is itself snapped to
## Quake's 8-unit grid by the importer (for DC voxel alignment), which is why fine
## grids already land correctly and only coarse ones drift.
var _map_center := Vector3.ZERO
var _aligned_offset := Vector3.ZERO
var _source_hash: String = ""
## The .MAP actually imported — the local dev-box copy, or the one fetched off the
## mesh. Anything that re-reads the file afterwards (player_start) must use THIS,
## not MAP_PATH, or it silently no-ops on exactly the peers the overlay is for.
var _map_path_used: String = ""
## info_player_start in world space, once the import has resolved it. Subclasses
## anchor to this rather than to a hand-picked constant.
var _start_pos := Vector3.ZERO
var _has_start := false
## Where the level's origin lands. ZERO for a standalone arena — there is nothing
## to clear. map_dc_world lifts it to sit above the procedural ground, which is
## what map_dc_terrain's (0,20,0) has always been for.
func world_offset() -> Vector3:
return Vector3.ZERO
## The SDF field the level's volume is subtracted from, or null when there is no
## procedural world to subtract it from.
##
## NULL IS NOT "SKIP AN OPTIONAL STEP" — it is the standalone-arena case, where
## §5a's seam genuinely vanishes because nothing else occupies the space. When a
## field IS returned the carve becomes load-bearing: it is the only thing keeping
## the terrain out of the level's rooms until §5a.2's convex decomposition of the
## interior replaces it. Returning null from a map that HAS a DCTerrain fills the
## level with hillside.
func carve_field() -> Node:
return null
func _import_from(map_path: String) -> void:
if not FileAccess.file_exists(map_path):
push_warning("[MapDCLevel] %s not found — empty level, placeholder floor kept" % map_path)
return
_map_path_used = map_path
var t0 := Time.get_ticks_msec()
var importer := QuakeMapImporter.new()
# Per-face UV projections need each texture's ORIGINAL size; header-only read.
# Through the resolver: this machine's own copy if it has one, else the room's.
# Was FileAccess.file_exists(WAD_PATH) — false on every peer but the author's.
var _wad_for_dims := String(_wad_paths()[0])
var tex_dims: Dictionary = QuakeWadLoader.load_dims(_wad_for_dims) if _wad_for_dims != "" else {}
# light_parent=self: 265 shadowless Quake OmniLights give the interiors something
# to read against.
# include_sky=true: a Quake sky brush is SOLID (it blocks the player) and only
# its rendering is special. The DCTerrain path has always dropped them, which
# leaves holes in the map boundary — the exact-mesh level seals them.
#
# The offset and the carve come from the two hooks below rather than being
# written here, because they are the ONLY things map_dc_world needs to differ
# on. Everything past this line — textures, sky lights, rtlights, entities, the
# gate, persistence, the editor — is identical for an arena and for a level
# embedded in a procedural world, and copying it into a second map is how the
# duplicate spawners happened (hyper-games #9 item 9).
var field: Node = carve_field()
var n: int = importer.import_file(level, map_path, WORLD_SCALE, world_offset(),
1.0, self, field != null, tex_dims, true, field)
if n <= 0:
push_warning("[MapDCLevel] import stamped 0 brushes — placeholder floor kept")
return
# add_brush only marks the store dirty; the node is already _ready so nothing
# will rebuild on its own.
_map_center = importer.out_map_center
_aligned_offset = importer.out_aligned_offset
level.rebuild()
_importer_for_retexture = importer
_hook_retexture()
_publish_pbr.call_deferred(PackedStringArray(importer.material_names))
_spawn_sky_area_lights(importer, _apply_textures(importer))
_spawn_rtlights(importer)
_spawn_entities(importer)
_report_gate(n, Time.get_ticks_msec() - t0)
_refresh_brush_view()
# AFTER the import: the outliner's derived groups are counts over the brush
# set, so registering at _ready would build a tree over an empty level.
_register_outliner()
_register_asset_browser()
drop_placeholder_floor()
_move_player_to_start(importer)
# AFTER _move_player_to_start: it anchors the map's teleporters to info_player_start
# by walking the map's DIRECT children, which adoption moves under SceneObjects.
_adopt_scene_objects()
## Texture the level. DCLevel emits DCTerrain's exact vertex encoding (COLOR.r =
## material_id/255, CUSTOM0.xy = per-face projection candidates) precisely so this
## works, but it has no set_texture_atlas / set_projection_table of its own — so we
## build the material here in GDScript and set it as material_override on its
## MeshInstance3D. Nothing in the GDExtension needs to change.
## Material names of the form R<region>S<sub> encode a HIERARCHY: the PVS region
## and the geometry sub-room inside it. Region picks the hue, sub picks the value,
## so a coarse region reads as one colour family and its subdivisions are visible
## within it. "XX" in either field marks a face the two methods disagree on and is
## painted white.
func _apply_room_palette(mat: ShaderMaterial, importer: QuakeMapImporter) -> void:
var names: Array = importer.material_names
var img := Image.create(256, 1, false, Image.FORMAT_RGBF)
img.fill(Color(0.5, 0.5, 0.5))
var re := RegEx.new()
re.compile("^R(\\d+|XX)S(\\d+|XX)$")
var regions := {}
for n in names:
var m := re.search(String(n))
if m and m.get_string(1) != "XX":
regions[int(m.get_string(1))] = true
var keys: Array = regions.keys(); keys.sort()
for i in range(names.size()):
var idx := i + 1 # atlas/material ids are 1-based
if idx > 255: break
var m := re.search(String(names[i]))
if m == null:
continue
if m.get_string(1) == "XX" or m.get_string(2) == "XX":
img.set_pixel(idx, 0, Color(1, 1, 1)) # disagreement between methods
continue
var r := int(m.get_string(1))
var sub := int(m.get_string(2))
var slot: int = keys.find(r)
var hue: float = float(slot) / maxf(1.0, float(keys.size()))
# Value walks DOWN with the sub-room index so the hierarchy is legible:
# same hue == same PVS region, different brightness == different geometry room.
var val: float = clampf(1.0 - 0.16 * float(sub), 0.32, 1.0)
img.set_pixel(idx, 0, Color.from_hsv(hue, 0.72, val))
mat.set_shader_parameter("room_palette", ImageTexture.create_from_image(img))
mat.set_shader_parameter("use_palette", true)
print("[MapDCLevel] room palette: %d materials, %d regions" % [names.size(), keys.size()])
func _apply_textures(importer: QuakeMapImporter) -> Texture2DArray:
var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
if mi == null:
return null
# Lives HERE, next to the map, and deliberately NOT in res://shaders/ — that
# directory carries a .gdignore (it holds dc-terrain's GPU compute .glsl files,
# which the GDExtension loads itself via dladdr and Godot must not try to
# import). A .gdshader placed there is invisible to the importer and absent
# from the exported .pck, so load() returns null and the level ships
# untextured. That is exactly how 0.771.0 shipped.
var shader: Shader = load("res://scenes/maps/dc_level.gdshader")
if shader == null:
push_warning("[MapDCLevel] dc_level.gdshader failed to load — level stays untextured")
return null
var mat := ShaderMaterial.new()
mat.shader = shader
var atlas: Texture2DArray = null
# RESOLVED ONCE, used for the atlas AND the native sky read below. Both used to
# test FileAccess.file_exists(WAD_PATH) against an absolute path in the
# authoring machine's /storage/projects/gamedev/quake tree, so on every other
# peer both silently failed and the level rendered untextured, with no error.
var _wp := _wad_paths()
var wad_p := String(_wp[0])
var pal_p := String(_wp[1])
if wad_p == "" or pal_p == "":
print("[MapDCLevel] WAD/palette not local yet — untextured until they arrive")
# Atlas layer index == material_id - 1, matching the importer's 1-based ids.
if wad_p != "" and pal_p != "":
var res: Dictionary = QuakeWadLoader.load(wad_p, pal_p,
PackedStringArray(importer.material_names), 64)
if res.has("array"):
atlas = res["array"]
mat.set_shader_parameter("tex_atlas", atlas)
# `layers` is a DICTIONARY (Variant type 27), not an Array. Casting it
# with `as Array` yields null and .size() on null hard-crashes the game
# with no GDScript error — which is exactly how 0.772.0 died, right
# after the atlas finished building. map_dc_terrain.gd has always used
# the duck-typed form; the cast was invented here. Do not add it back.
var layers: Variant = res["layers"]
print("[MapDCLevel] WAD atlas: %d layers" % int(layers.size()))
# Per-face projection table as an RGBA32F data texture — the same layout
# DCTerrain::set_projection_table builds in C++: 4 texels per row
# (layer,u_off,v_off,0)(u_axis,0)(v_axis,0)(geometric normal,0), one row per
# projection entry, fetched by the vertex's 1-based projection index.
var entries: int = importer.projections.size() / 16
if entries > 0:
var img := Image.create_from_data(4, entries, false, Image.FORMAT_RGBAF,
importer.projections.to_byte_array())
mat.set_shader_parameter("projection_table", ImageTexture.create_from_image(img))
mat.set_shader_parameter("has_projection_table", true)
mat.set_shader_parameter("projection_rows", entries)
print("[MapDCLevel] projection table: %d entries" % entries)
# Sky faces emit (stained-glass read). Collect the DISTINCT sky material ids —
# a map can mix SKY1/SKY4 — and hand up to 4 to the shader, which compares them
# against the per-vertex material id. -1 marks an unused slot.
var sky_ids: Array[float] = []
for v in importer.sky_volumes:
var m := float(int(v["mat"]))
if m > 0.0 and not sky_ids.has(m):
sky_ids.append(m)
var packed := Vector4(-1.0, -1.0, -1.0, -1.0)
for i in mini(sky_ids.size(), 4):
packed[i] = sky_ids[i]
mat.set_shader_parameter("sky_mats", packed)
# The sky at NATIVE 256x128 — two 128x128 layers side by side. The atlas resizes
# every layer to a square, which blends the cloud and star halves into the mottled
# noise the ceiling was showing, so the sky is loaded separately and unsquashed.
if not sky_ids.is_empty():
var sky_name: String = importer.material_names[int(sky_ids[0]) - 1]
var sky_img: Image = null
if wad_p != "" and pal_p != "":
sky_img = QuakeWadLoader.load_native(wad_p, pal_p, sky_name)
if sky_img != null:
mat.set_shader_parameter("sky_tex", ImageTexture.create_from_image(sky_img))
mat.set_shader_parameter("has_sky_tex", true)
print("[MapDCLevel] sky texture %s at native %dx%d" % [
sky_name, sky_img.get_width(), sky_img.get_height()])
else:
push_warning("[MapDCLevel] could not decode sky texture %s" % sky_name)
mat.set_shader_parameter("sky_emission", SKY_EMISSION)
if sky_ids.size() > 4:
push_warning("[MapDCLevel] %d distinct sky materials, shader holds 4" % sky_ids.size())
print("[MapDCLevel] emissive sky materials: %s (emission %.1f)" % [str(sky_ids), SKY_EMISSION])
# PBR: roughness-from-gloss, emissive glow, DarkPlaces-derived normals. The loader
# and the assets both already existed and were used by map_dc_terrain.gd:359 — the
# level simply never called them, so it rendered albedo-only with a constant
# ROUGHNESS 0.9. Layers come back in the SAME order as the diffuse atlas, so a
# fragment's material layer indexes all three consistently.
#
# Guarded on the directory: this is a dev-authoring path (like WAD_PATH above), and
# a machine without the texture build should lose PBR, not the level.
var pbr_dir := _pbr_dir(PackedStringArray(importer.material_names))
if pbr_dir != "":
var pbr: Dictionary = QuakeWadLoader.load_pbr(
pbr_dir, PackedStringArray(importer.material_names), 64)
if pbr.has("glow") and pbr.has("norm"):
mat.set_shader_parameter("glow_atlas", pbr["glow"])
mat.set_shader_parameter("norm_atlas", pbr["norm"])
mat.set_shader_parameter("use_pbr", true)
# normal_strength is deliberately NOT set: the shader defaults it to 1.0 and
# DarkPlaces applies the normal unscaled, so 1.0 IS the reference value.
# map_dc_terrain.gd does not set it either. Adding a tuned constant here
# would make the level disagree with both the terrain and the oracle.
# Report COVERAGE, not success. Every name resolves to a layer whether or
# not a map existed for it (missing → black → matte, no emission), so
# "PBR on" says nothing about how much of the level is actually covered.
print("[MapDCLevel] PBR atlases: gloss %d/%d, glow %d/%d, normal %d/%d layers" % [
int(pbr["n_gloss"]), importer.material_names.size(),
int(pbr["n_glow"]), importer.material_names.size(),
int(pbr["n_norm"]), importer.material_names.size()])
else:
push_warning("[MapDCLevel] PBR build dir present but no atlases came back — level stays albedo-only")
else:
print("[MapDCLevel] no PBR build locally and none from the room — albedo-only")
mat.set_shader_parameter("use_textures", not flat_room_colors)
mat.set_shader_parameter("flat_mats", flat_room_colors)
if flat_room_colors:
_apply_room_palette(mat, importer)
LevelPackMaterial.wear(level, mat) # LevelMesh AND LevelPockets
return atlas
## Water/lava/slime, teleporters and func_door movers. The importer already recovers
## all three; map_dc_terrain had private spawners for them and this map called none —
## the same gap rtlights had. Now shared in QuakeEntities so any map gets them.
## ⛔ RESOLVED BUT DELIBERATELY NOT RETRIED. Unlike _apply_textures, every call
## below SPAWNS NODES, so re-running this when a late WAD lands would duplicate
## every liquid, teleporter and door — the hyper-games #9 item 9 failure. A peer
## that joins before the WAD arrives therefore loses these for the life of the
## map load, which is a real gap and the honest cost of not making the spawners
## idempotent. Making them removable-and-rebuildable is the fix, and it is its own
## change.
func _spawn_entities(importer: QuakeMapImporter) -> void:
var _ep := _wad_paths()
var e_wad := String(_ep[0])
var e_pal := String(_ep[1])
if e_wad == "" or e_pal == "":
print("[MapDCLevel] no WAD/palette — liquids, teleporters and doors skipped")
return
var liquids := QuakeEntities.spawn_liquids(self, importer, e_wad, e_pal)
var tp: Array = QuakeEntities.spawn_teleporters(self, importer, e_wad, e_pal)
var doors := QuakeEntities.spawn_doors(self, importer, e_wad, e_pal)
# AFTER the spawn, and from what it produced: the links are drawn over the live
# TeleportTrigger nodes, so a line exists exactly where the game will actually
# move the player. A re-import respawns that container, so the lines must be
# rebuilt with it — which they are, being in the same function.
_ensure_entity_links().refresh(get_node_or_null("QuakeTeleporters"))
print("[MapDCLevel] liquids %d/%d | teleporters %d wired (%d dests) + %d pads | doors %d | links %d" % [
liquids, importer.liquid_volumes.size(), int(tp[0]), int(tp[2]), int(tp[1]), doors,
_ensure_entity_links().link_count()])
## Replace the raw .MAP light entities with the curated DarkPlaces rtlights.
##
## The importer spawns an OmniLight3D per Quake `light*` entity into a "QuakeLights"
## child — 267 of them on START — but that path is a GUESS: omni_range is
## brightness x world_scale, energy is clamp(brightness/200, 0.5, 4.0), and shadows
## are hard-disabled. RtlightManager reads the authored .rtlights file instead,
## canonicalized against DarkPlaces r_shadow.c: real radius, per-light
## energy = ambient+diffuse+specular, `!`=noshadow and realtime/corona filtering,
## and EMA importance + hysteresis for shadow selection.
##
## map_dc_terrain has always done this after its KEY_M import; map_dc_level never
## did, which is why this level was lit by the fallback. Load order matters: this
## must run AFTER the import, because the rtlight transform reuses the importer's
## out_map_center / out_aligned_offset so lights land in the same world space as
## the geometry.
func _spawn_rtlights(importer: QuakeMapImporter) -> void:
# A generated level carries its own light entities (authored by the level
# designers of the maps its germs came from). RTLIGHTS_PATH is START-specific,
# so loading it here would place START's lights in OUR map's space and then
# delete the correct ones.
if use_map_lights:
print("[MapDCLevel] use_map_lights — keeping this map's own light entities")
return
# Resolved, but NOT retried — RtlightManager is add_child()ed below, so a
# second pass would stack managers. Same reasoning as _spawn_entities.
var rt_path := asset_path("rtlights", "start.rtlights", RTLIGHTS_PATH)
if rt_path == "":
print("[MapDCLevel] no rtlights at %s — keeping the .MAP light entities" % RTLIGHTS_PATH)
return
var mgr := RtlightManager.new()
mgr.name = "RtlightManager"
add_child(mgr)
var n := mgr.setup(rt_path, importer.out_map_center, WORLD_SCALE,
importer.out_aligned_offset)
if n > 0:
# Only drop the fallback once the real lights actually loaded — otherwise a
# bad rtlights file would leave the level pitch black.
var maplights := get_node_or_null("QuakeLights")
if maplights != null:
maplights.queue_free()
print("[MapDCLevel] rtlights: %d loaded — .MAP light entities replaced" % n)
else:
mgr.queue_free()
print("[MapDCLevel] rtlights loaded 0 — keeping the .MAP light entities")
## One AreaLight3D per sky brush, sized to that brush's inward face and textured
## with its sky texture — the map author's own boundary marking driving the light.
##
## RUNTIME-INSTANTIATED ON PURPOSE, never declared in the .tscn. AreaLight3D is
## Godot 4.7+ (PR #108219, merged 2026-04-14); the CI export runs 4.6, where the
## class does not exist. A .tscn node of an unknown type silently degrades to a
## placeholder Node — exactly how 0.768.0 shipped an inert DCLevel — so the class
## is checked at runtime and simply skipped on 4.6. Under the godot-rtx build
## (4.7-beta, which has it) the lights appear with no other change.
func _spawn_sky_area_lights(importer: QuakeMapImporter, atlas: Texture2DArray) -> void:
if not ClassDB.class_exists("AreaLight3D"):
print("[MapDCLevel] AreaLight3D unavailable (needs 4.7+ / godot-rtx build) — sky lights skipped")
return
# OFF by default. Godot flags AreaLight3D as expensive (there is a merged PR
# putting its cluster iterations behind a spec constant), and 19 of them over a
# whole level is a real frame cost. Experimental like path tracing, so it is a
# setting rather than something every player pays for silently.
if not bool(Settings.get_value("sky_area_lights")):
print("[MapDCLevel] sky area lights disabled (Settings > Sky Area Lights)")
return
var vols: Array = importer.sky_volumes
if vols.is_empty():
return
var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
var level_centre: Vector3 = mi.get_aabb().get_center() if mi != null else Vector3.ZERO
var container := Node3D.new()
container.name = "SkyAreaLights"
add_child(container)
var made := 0
var tex_ok := 0
for v in vols:
var c: Vector3 = v["center"]
var h: Vector3 = v["half"]
# Which face of this box looks INTO the level? The one whose outward normal
# most nearly points at the level centre. A sky brush is a boundary slab, so
# that face is the one the player can actually see and be lit by.
# A sky brush is a SLAB: its emitting face is the broad one, which is
# perpendicular to the THINNEST axis. Choosing by "which face points at the
# level centre" picked the end cap instead — [MEASURED] a half=(0.224,2.8,7.84)
# wall produced a 0.448 x 5.6 m light off its end rather than the 5.6 x 15.68 m
# face. Pick the thin axis for the normal, then use the level centre only to
# choose WHICH WAY along it to face.
var axis := 0
for a in [1, 2]:
if h[a] < h[axis]:
axis = a
var to_centre := level_centre - c
var sign := 1.0 if to_centre[axis] >= 0.0 else -1.0
var n := Vector3.ZERO
n[axis] = sign # inward-facing normal
# Nudged INSIDE the room, not left coplanar with the face. The brush is solid,
# rendered geometry: a light sitting exactly on (or within) its surface is
# occluded by the very brush it belongs to.
var face_centre := c + n * (h[axis] + SKY_LIGHT_INSET)
var dims: Array[float] = []
for a in 3:
if a != axis:
dims.append(h[a] * 2.0)
var light: Node3D = ClassDB.instantiate("AreaLight3D")
container.add_child(light)
light.global_position = face_centre
# Light is emitted along -Z of the basis, and look_at points -Z at the
# target. Pick an up vector that is not parallel to n or the basis is
# degenerate — a ceiling's normal IS Vector3.UP.
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
light.look_at(face_centre + n, up)
light.set("area_size", Vector2(dims[0], dims[1]))
# x4 the face size reached ~89 m, so a single sky slab lit most of the level
# and every fragment in that volume paid for it. x1.5 keeps the glow local to
# the opening it comes from.
light.set("area_range", maxf(dims[0], dims[1]) * 1.5)
# area_normalize_energy DIVIDES energy by surface area. With the default
# energy of 1.0 an ~88 m^2 sky slab emitted ~0.011 — created, correct, and
# completely invisible, which is exactly what "I don't see any arealight"
# was. Off, with an explicit energy instead, so brightness is a number we
# chose rather than a side effect of how big the brush happens to be.
light.set("area_normalize_energy", false)
light.set("light_energy", SKY_LIGHT_ENERGY)
var textured := false
if atlas != null:
var layer: int = maxi(int(v["mat"]) - 1, 0)
if layer < atlas.get_layers():
var img := atlas.get_layer_data(layer)
if img != null:
light.set("area_texture", ImageTexture.create_from_image(img))
textured = true
if textured:
tex_ok += 1
made += 1
# Report what actually happened. The previous version claimed "textured from the
# sky brush" unconditionally while every light in fact had area_texture unset —
# a log line that asserted an outcome it never checked.
print("[MapDCLevel] sky area lights: %d (energy %.1f, %d textured)" % [
made, SKY_LIGHT_ENERGY, tex_ok])
## Print measured-vs-target for every acceptance number in §6.2/§10, so a divergence
## is visible in the log rather than needing to be eyeballed.
func _report_gate(stamped: int, wall_ms: int) -> void:
var s: Dictionary = level.get_stats()
print("[MapDCLevel] import: %d brushes stamped in %d ms (rebuild %.1f ms)" % [
stamped, wall_ms, float(s.get("build_ms", 0.0))])
# Targets are the SKY-INCLUSIVE numbers, because that is what this map imports.
# §6.2's published figures (12747 tris / 6487 faces / 1164 colliders) were
# measured solid-only; promoting START's 19 sky brushes to solid adds 428
# triangles, 225 faces and 19 colliders. [MEASURED] via
# scenes/test/dc_level_gate.gd, which still hard-gates the solid-only config so
# the csg4 port keeps a regression check against the published oracle.
# colliders is 1164, NOT 1183: all 1183 brushes render and clip, but the 19 sky
# brushes are non-solid so you can jump through them, so the collider count
# lands back on the solid-only figure. Triangles/faces stay at the sky-inclusive
# numbers precisely BECAUSE sky still clips.
for row in [
["triangles", int(s.get("triangles", 0)), 13175],
["faces", int(s.get("faces", 0)), 6712],
["colliders", int(s.get("colliders", 0)), 1164],
["sky_brushes", int(s.get("sky_brushes", 0)), 19],
]:
var got: int = row[1]
var want: int = row[2]
print("[MapDCLevel] %-10s %7d target %7d %s" % [
row[0], got, want, "OK" if got == want else "DIVERGES"])
# 34288.0 is the sky-inclusive area; §6.2's 32101 m^2 was solid-only. Missed on
# the first pass — the count targets were re-baselined for sky and this one was
# not, so it read "34288.0 target 32101.0" and looked like a divergence.
print("[MapDCLevel] %-10s %7.1f target %7.1f m^2" % [
"area", float(s.get("surface_area", 0.0)), 34288.0])
print("[MapDCLevel] %-10s %7.4f target <=%.4f deg (sharp edges)" % [
"normal dev", float(s.get("max_normal_deviation", 0.0)), 0.034])
print("[MapDCLevel] brushes=%d faces_removed=%d faces_fragmented=%d vertices=%d" % [
int(s.get("brushes", 0)), int(s.get("faces_removed", 0)),
int(s.get("faces_fragmented", 0)), int(s.get("vertices", 0))])
_report_winding()
## Every metric above is INVARIANT under a global inversion — flip every triangle
## and the counts, area and normal deviation are all unchanged. 0.768.0 shipped
## inside-out with a fully passing gate for exactly that reason. So check winding
## explicitly, against a mesh Godot itself generates rather than against a
## remembered convention: for a correctly-wound front face, cross(v1-v0, v2-v0)
## points AWAY from the shading normal.
func _report_winding() -> void:
var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
if mi == null or mi.mesh == null or mi.mesh.get_surface_count() == 0:
return
var want := _agree_fraction(BoxMesh.new().surface_get_arrays(0))
var got := _agree_fraction(mi.mesh.surface_get_arrays(0))
var ok := absf(want - got) < 0.5
print("[MapDCLevel] %-10s %6.1f%% godot %6.1f%% %s" % [
"winding", got * 100.0, want * 100.0,
"OK" if ok else "INVERTED — renders inside out"])
## Fraction of triangles whose geometric normal agrees with the stored shading
## normal. Godot's own meshes read 0%; a value near 100% means inverted winding.
func _agree_fraction(arrays: Array) -> float:
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
var agree := 0
var total := 0
for t in range(0, idx.size(), 3):
var geo := (verts[idx[t + 1]] - verts[idx[t]]).cross(verts[idx[t + 2]] - verts[idx[t]])
if geo.length() < 1e-12:
continue
if geo.normalized().dot(norms[idx[t]]) > 0.0:
agree += 1
total += 1
return float(agree) / float(maxi(total, 1))
## Put the local player where Quake intends, instead of the teleporter's guessed
## drop point (which is now inside the level's interior volume).
func _move_player_to_start(importer: QuakeMapImporter) -> void:
var f := FileAccess.open(_map_path_used if _map_path_used != "" else _map_path(), FileAccess.READ)
if f == null:
return
var start: Dictionary = importer.player_start(f.get_as_text())
f.close()
if start.is_empty():
print("[MapDCLevel] no info_player_start — leaving player at the teleport drop")
return
# Recorded whether or not a local player exists to move — a headless peer has
# none, and map_dc_world still needs somewhere defensible to put the way out.
# A return teleporter placed by guesswork ends up inside rock.
_start_pos = start["pos"]
_has_start = true
_anchor_teleporters_to_start()
var players := get_tree().root.get_node_or_null("Game/Players")
if players == null:
return
for p in players.get_children():
if not p.is_multiplayer_authority():
continue # only ever move OUR player; peers own their own position
# +1 m: Quake's entity origin is the player's centre, not their feet.
p.global_position = (start["pos"] as Vector3) + Vector3(0, 1.0, 0)
p.velocity = Vector3.ZERO
if "grapple_hooked" in p:
p.grapple_hooked = false
print("[MapDCLevel] player -> info_player_start %s" % str(p.global_position))
return
## Move this scene's teleporters to the resolved info_player_start.
##
## Their scene positions are authored constants that suit START.MAP; for any other
## .MAP - a generated level especially - they land inside rock, which is exactly the
## "way out placed by guesswork" the comment above warns about. _start_pos is the one
## point in the level we KNOW is open, so anchor to it.
##
## Offset in a ring rather than ON the start: the teleporter hitbox is 6 m tall and
## the player spawns at the centre, so a co-located pad fires instantly and loops.
const TELEPORT_RING := 4.5
func _anchor_teleporters_to_start() -> void:
if not _has_start:
return
var pads: Array[Node3D] = []
for c in get_children():
if c is Node3D and "target_map" in c:
pads.append(c)
if pads.is_empty():
return
for i in range(pads.size()):
var a: float = TAU * float(i) / float(pads.size())
pads[i].global_position = _start_pos + Vector3(cos(a) * TELEPORT_RING, 0.0,
sin(a) * TELEPORT_RING)
print("[MapDCLevel] anchored %d teleporter(s) to info_player_start" % pads.size())
## Dev trigger for carving — the first thing that actually DRIVES the edit API.
##
## DCLevel has exposed apply_carve_sphere/undo_carve to GDScript since the level
## pivot landed, and nothing in the game called either of them, so the whole carve
## path was unreachable from a running build. This is the smallest thing that makes
## it reachable and therefore testable in the place it has to work.
##
## K carve a 0.6m sphere where you are looking
## Shift+K undo the last carve
##
## Still a dev keybind on the map rather than a player ability, but no longer for
## the old reason. Edits ARE durable now: `world:level:` is in both
## MAP_SCOPED_PREFIXES and AUTHOR_SCOPED_PREFIXES and is routed to DURABLE (it was
## missing from the route table until 0.843.0, which is why nothing written before
## that survived a reload), and every edit here is a journal record that folds back
## on load. What keeps it a dev binding is the UI, not the storage.
## NO UNDO STACK LIVES HERE. It used to: an Array of this session's carve dots,
## popped by Shift+K — which meant undo could not reach a previous session's edits
## and could not reach a face move or a fill at all. The journal already records
## every edit with its author and seq, so "my most recent edit" is a QUERY
## (LevelPersistence.undo_last), and it survives restarts because there is nothing
## to survive. A second source of truth beside the journal is the exact mistake the
## journal exists to prevent.
var _persist: LevelPersistence = null
## Durable carves. Created here rather than in the .tscn so the scene stays a
## plain instance of the map — the persistence layer is behaviour, not content.
## The wireframe. Created here rather than in the .tscn for the same reason the
## persistence node is: it is behaviour, not content.
var _brush_view: BrushView = null
## The entity wiring, which the rendered level shows nothing of — hyper-games #31.
var _entity_links: EntityLinks = null
## The active grid on the aimed surface (#31). Shown/hidden per player (pref "grid_view").
var _editor_grid: EditorGrid = null
## The operator chain. One at a time, Escape first-wins — see operator_stack.gd.
var _ops: OperatorStack = null
var _knife: KnifeOperator = null
var _move: MoveOperator = null
var _rotate: RotateOperator = null
## A selected shape's face handle drag (§17.7 Q3a). Preloaded, not by class name: a new
## class_name is unknown until the next import refreshes the class cache.
const ShapeHandleOperatorScript := preload("res://scenes/maps/operators/shape_handle_operator.gd")
var _handle_op: LevelOperator = null
var _scale: ScaleOperator = null
var _mirror: MirrorOperator = null
var _curve_op: CurveOperator = null
var _cable_op: CurveOperator = null # the curve tool in cable mode (#120 L3)
var _curve_edit: CurveEditOperator = null
var _grab_op: ElementOperator = null
var _extrude: ExtrudeOperator = null
var _box: CreateBoxOperator = null
## Every operator needs the same two references and the same parenting; doing it four
## times by hand is three chances to forget one.
func _make_op(op: LevelOperator, node_name: String) -> LevelOperator:
op.name = node_name
op.level = level
op.map = self
add_child(op)
op.load_prefs() # E4: the real map remembers each tool's last settings
return op
## The active tool's readout for the crosshair label (BuildHud.readout_provider).
func _op_readout() -> String:
return _ops.active.readout() if _ops != null and _ops.active != null else ""
func _ensure_ops() -> OperatorStack:
if _ops != null and is_instance_valid(_ops):
return _ops
_ops = OperatorStack.new()
_ops.name = "OperatorStack"
add_child(_ops)
_knife = _make_op(KnifeOperator.new(), "Knife")
_move = _make_op(MoveOperator.new(), "Move")
_rotate = _make_op(RotateOperator.new(), "Rotate")
_handle_op = _make_op(ShapeHandleOperatorScript.new(), "FaceHandle")
_scale = _make_op(ScaleOperator.new(), "Scale")
_mirror = _make_op(MirrorOperator.new(), "Mirror")
_curve_op = _make_op(CurveOperator.new(), "Curve")
_cable_op = CurveOperator.new()
_cable_op.as_cable = true
_cable_op = _make_op(_cable_op, "Cable")
_curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit")
_drop_op = _make_op(DropCableOperatorScript.new(), "DropCable") # #120 L3 §14.3
_edges_op = _make_op(CableEdgesOperatorScript.new(), "CableEdges") # #120 L3 §14.5
_bundle_op = _make_op(CableBundleOperatorScript.new(), "CableBundle") # #120 L3 §14.6
_grab_op = _make_op(ElementOperator.new(), "Grab")
_extrude = _make_op(ExtrudeOperator.new(), "Extrude")
_box = _make_op(CreateBoxOperator.new(), "CreateBox")
_make_op(BoxCutterOperator.new(), "BoxCutter") # #120 L2 §13: found by name ("BoxCutter")
# The stack scroll preview ends when the selection moves off its subject or a tool starts.
selection_changed.connect(func() -> void: preload("res://scenes/maps/stack_ops.gd").clear_if_moved(self))
_ops.changed.connect(func(op: LevelOperator) -> void: if op != null: preload("res://scenes/maps/stack_ops.gd").scroll_clear(self))
# The HUD shows which operator is live and what it is set to, so a modal tool is
# never a mode you are in without being told.
_ops.changed.connect(func(_op: LevelOperator) -> void: _update_op_hud())
# While a tool is up the panels take no hover either (BuildDock.set_modal; #124).
_ops.changed.connect(func(_op: LevelOperator) -> void: _sync_modal())
GameMode.build_submode_changed.connect(func(_m: String) -> void: _sync_modal())
return _ops
func _update_op_hud() -> void:
if BuildHud == null:
return
BuildHud.set_operator(_ops.status_line() if _ops != null else "")
# THE TOOL'S HELP (H, #120 F3): its key table with the state inline, while open.
var help := ""
if _ops != null and _ops.active != null and _ops.active.has_modal() and _ops.active.modal().help_open:
help = _ops.active.modal().help_text(_ops.active.op_name().to_upper())
if BuildHud.has_method("set_tool_help"):
BuildHud.set_tool_help(help)
var mode_line := _sel_mode
if _sel_brushes.size() > 1:
# Said out loud, because a selection you have forgotten the size of is how a
# CSG operation eats a brush you did not mean to include.
mode_line = "%s · %d selected, active #%d" % [_sel_mode, _sel_brushes.size(), _sel_brush]
BuildHud.set_select_mode(mode_line)
## The link renderer hangs off the MAP, not off `level` like the wireframe does:
## its vertices are the trigger volumes' world positions and the destinations the
## importer resolved, neither of which lives in the brush store's space. The node
## is top_level, so the parent's transform is not applied to them either way.
func _ensure_entity_links() -> EntityLinks:
if _entity_links != null and is_instance_valid(_entity_links):
return _entity_links
_entity_links = EntityLinks.new()
_entity_links.name = "EntityLinks"
add_child(_entity_links)
_entity_links.visible = _links_visible()
return _entity_links
func _ensure_editor_grid() -> EditorGrid:
if _editor_grid != null and is_instance_valid(_editor_grid):
return _editor_grid
_editor_grid = EditorGrid.new()
add_child(_editor_grid)
_editor_grid.hide_grid()
return _editor_grid
func _grid_view_on() -> bool:
return overlay_shown("grid") # an overlay since keymap step 6.5
## Per frame: the grid follows the crosshair while building in the Level editor.
## `hit` is the frame's one crosshair ray, shared with the brush-view aim.
func _update_editor_grid(hit: Dictionary) -> void:
var want := GameMode.is_building() and not in_bot_editor() and _grid_view_on() \
and hit.has("position") and hit.has("normal")
if not want:
if _editor_grid != null and is_instance_valid(_editor_grid) and _editor_grid.visible:
_editor_grid.hide_grid()
return
_ensure_editor_grid().show_at(hit["position"], hit["normal"], _grid_step())
func _ensure_brush_view() -> BrushView:
if _brush_view != null and is_instance_valid(_brush_view):
return _brush_view
_brush_view = BrushView.new()
_brush_view.name = "BrushView"
_brush_view.level = level
level.add_child(_brush_view) # the store's space, like the face highlight
_push_overlays(_brush_view)
_brush_view.set_shown(GameMode.is_building())
return _brush_view
## Geometry changed, so the outlines are stale. Cheap to call — it only regenerates
## when the view is actually visible.
func _refresh_brush_view() -> void:
var v := _ensure_brush_view()
if v.visible:
v.refresh()
## Corner dots are what you aim at to pick a VERTEX. In edge or face mode they are
## the wrong affordance drawn over the right one — which is why an edge selection
## "shows up as two vertices": the line was there, with a corner dot blazing at each
## end of it.
func _sync_handle_visibility() -> void:
var v := _ensure_brush_view()
v.show_handles = (_sel_mode == "vertex")
## The organizer — hyper-games #28. Registered by the MAP for the same reason the
## terrain panel is: BuildDock.register is idempotent on id, so re-entering a map
## replaces the panel instead of stacking a second one.
##
## Keyed by the SOURCE HASH, like the shell cache, so collections made in one
## level do not show up in another. Nothing here is durable or replicated — see
## OutlinerModel's header for why that is the decision that made collections
## buildable at all.
func _register_outliner() -> void:
if level == null or not is_instance_valid(level):
return
var Panel := load("res://scenes/game/outliner_panel.gd")
if Panel == null:
push_error("[MapDCLevel] outliner_panel.gd failed to load — no Outliner panel")
return
var key := ""
if _persist != null and _persist.has_method("find_source"):
key = String(_persist.find_source().get("hash", ""))
var panel := VBoxContainer.new()
panel.set_script(Panel)
panel.setup(self, key)
# RIGHT, alone. The organizer is a standing view of the whole scene; every
# other panel is a tool for whatever you are doing this minute. Sharing one
# edge meant the tree scrolled off the moment a second panel opened.
# The organizer area, over the right column (Blender's; docs/editor-areas.md §4.3).
BuildDock.register("outliner", "Outliner", panel, true, BuildDock.SIDE_ORGANIZER)
_outliner = panel
var _outliner: Node = null
## Authored scene objects, folded from the same journal as the brushes. Made
## before persistence because _persist is handed a reference to it.
func _ensure_objects() -> Node:
if _objects != null and is_instance_valid(_objects):
return _objects
_objects = SceneObjects.new()
_objects.name = "SceneObjects"
add_child(_objects)
return _objects
var _objects: Node = null
## CREATE A NODE — the durable half of hyper-games #28.
##
## Godot's _do_create ends in `set_owner`, which is what makes a node survive
## into the saved scene. There is no saved scene here, so the equivalent is that
## the create is JOURNALLED: two phase, exactly like a brush create, because the
## record needs its durable name before anything can be built under it.
##
## `xform` is where it goes, in world space. Returns the dot, or "" if refused.
func create_object(type_id: String, xform: Transform3D) -> String:
if not PrefabCatalogue.has(type_id):
push_warning("[MapDCLevel] no prefab '%s' — nothing created" % type_id)
return ""
_ensure_persistence()
var dot: String = _persist.reserve_dot()
if not _persist.record_at(dot, "object.create", {
"type": type_id, "xform": SceneObjects.xform_to(xform)}):
push_warning("[MapDCLevel] the journal refused an object.create")
return ""
# The fold builds it — here and on every other peer by the same path, so what
# you see is what they will see rather than a local object that happens to
# match. Same reason the brush operators do not add geometry directly.
_persist.refold()
print("[MapDCLevel] created %s at %s (%s)" % [type_id, str(xform.origin), dot])
return dot
## MOVE a created object — hyper-games #28, and what `,`/`.` were a stopgap for.
##
## Absolute, not a delta, because the fold replays the whole journal: a relative
## move would compound on every re-fold. So the record carries where the object
## ENDS UP, the last one in (seq, author) order wins, and undoing one falls back
## to the position before it with no inverse to compute.
func move_object(dot: String, xform: Transform3D) -> bool:
if dot == "":
return false
_ensure_persistence()
if String(_persist.instance_of(dot)) != "":
print("[MapDCLevel] %s is part of a placed asset — move the placement (or Make Real it)" % dot)
return false
var op: String = _persist.reserve_dot()
if not _persist.record_at(op, "object.transform", {
"target": dot, "xform": SceneObjects.xform_to(xform)}):
push_warning("[MapDCLevel] the journal refused an object.transform")
return false
_persist.refold()
return true
## SET PROPERTIES of a created object — object.set_prop (#41 §7 item 5).
##
## `props` is {prop id: wire value} (a colour / vec3 = 3 floats). ONE record, one
## gesture, so one Ctrl+Z takes back the whole change and History names it by
## `label` ("Set Colour"). Not filtered here: the fold applies only what the
## catalogue lists for this prefab, on every peer, so this writer and a remote one
## cannot disagree about what took.
func set_object_props(dot: String, props: Dictionary, label: String = "") -> bool:
if dot == "" or props.is_empty():
return false
_ensure_persistence()
_persist.begin_gesture(label)
var ok: bool = _persist.record_at(_persist.reserve_dot(), "object.set_prop",
{"target": dot, "props": props})
_persist.end_gesture()
if not ok:
push_warning("[MapDCLevel] the journal refused an object.set_prop")
return false
_persist.refold()
return true
## PLACE A BOT at the crosshair (the Bot editor's "Place bot"): a `bot` scene object
## with its brain + body, ONE gesture ("Add bot"), selected so the Object panel shows
## its settings at once. Returns the dot, or "".
func place_bot(brain: String, body: String) -> String:
if not BrainCatalogue.has(brain) or not PrefabCatalogue.BOT_BODIES.has(body):
return ""
_ensure_persistence()
_persist.begin_gesture("Add bot")
var dot := create_object("bot", object_spawn_transform())
if dot != "":
set_object_props(dot, {"brain": brain, "body": body})
_persist.end_gesture()
if dot != "":
select_object(dot)
return dot
## Brush-authoring actions, ignored in the Bot editor workspace.
const LEVEL_ONLY_ACTIONS: Array[String] = ["builder.create_box", "builder.carve", "builder.knife",
"builder.extrude", "builder.move_in", "builder.move_out", "builder.texture_apply",
"builder.texture_pick", "build_mode.csg_subtract", "build_mode.csg_intersect",
"build_mode.csg_merge", "build_mode.csg_discard", "build_mode.faces_hull",
"build_mode.faces_extrude", "build_mode.sel_vertex", "build_mode.sel_edge",
"build_mode.sel_face", "build_mode.sel_brush"]
## True in the Bot editor workspace (BuildDock).
func in_bot_editor() -> bool:
var dock := get_node_or_null("/root/BuildDock")
return dock != null and dock.has_method("workspace") and String(dock.workspace()) == "bot"
## PLACE A MODEL the room already has, by content hash, at the crosshair. ONE gesture
## ("Add model <name>"): the create and its first set_prop undo together, or an undo
## would leave an empty grey box behind. Returns the dot, or "".
func place_model(oid: String, model_name: String) -> String:
if not ModelCache.is_oid(oid):
return ""
_ensure_persistence()
_persist.begin_gesture("Add model %s" % model_name)
var dot := create_object("model", object_spawn_transform())
if dot != "":
set_object_props(dot, {"asset": oid, "name": model_name})
_persist.end_gesture()
if dot != "":
select_object(dot)
return dot
## IMPORT a .glb from this machine's disk and place it (hyper-games #93 models plan;
## any member may import — user, 2026-10-03). {dot, oid, reason}.
##
## 1. GlbCheck HERE, before anything is published: the room should never be offered
## a file every peer would refuse.
## 2. Copy into user://content/<hash>.glb — the overlay serves a LOCATOR, so serving
## the user's own path would break the moment they moved the file; the content
## directory is ours, and a file there is named by what it is.
## 3. Name it in the room's library (world:assets:model:<name>, newest wins) so other
## members can place it too — placements themselves pin the HASH.
## 4. place_model.
func import_model(path: String) -> Dictionary:
var out := {"dot": "", "oid": "", "reason": ""}
var bytes := PackedByteArray()
if path.get_extension().to_lower() == "gltf":
# A .gltf is PACKED into a .glb here, side files and all (GltfPacker): the room
# only ever receives self-contained .glb bytes, so every peer's "no uri" rule holds.
var pk := GltfPacker.pack_file(path)
if not bool(pk["ok"]):
out["reason"] = String(pk["reason"])
return out
bytes = pk["bytes"]
else:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
out["reason"] = "cannot open %s" % path.get_file()
return out
if f.get_length() > GlbCheck.MAX_BYTES:
out["reason"] = "file too large (%d MB, limit %d)" % [f.get_length() >> 20, GlbCheck.MAX_BYTES >> 20]
return out
bytes = f.get_buffer(f.get_length())
f.close()
var v := GlbCheck.check(bytes)
if not bool(v["ok"]):
out["reason"] = String(v["reason"])
return out
if ContentOverlay == null or not ContentOverlay.is_ready():
out["reason"] = "asset transfer unavailable in this build"
return out
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(ModelCache.CONTENT_DIR))
var tmp := ModelCache.CONTENT_DIR.path_join("import-%d.glb" % Time.get_ticks_usec())
var w := FileAccess.open(tmp, FileAccess.WRITE)
if w == null:
out["reason"] = "cannot write the content cache"
return out
w.store_buffer(bytes)
w.close()
var oid: String = ContentOverlay.content_hash_of_file(tmp)
if not ModelCache.is_oid(oid):
out["reason"] = "could not hash the file"
return out
var final := ModelCache.path_of(oid)
if FileAccess.file_exists(final):
DirAccess.remove_absolute(ProjectSettings.globalize_path(tmp)) # our own scratch copy
else:
DirAccess.rename_absolute(ProjectSettings.globalize_path(tmp), ProjectSettings.globalize_path(final))
ContentOverlay.publish(final) # serve it from its content-addressed home
var model_name := _model_name_from(path.get_file().get_basename())
if AssetSync != null:
AssetSync.publish_asset("model", model_name, oid, bytes.size(), "glb")
out["oid"] = oid
out["dot"] = place_model(oid, model_name)
if out["dot"] == "":
out["reason"] = "the journal refused the placement"
print("[MapDCLevel] imported model %s (%s, %d tris) -> %s" % [model_name, oid.substr(0, 12), int(v["triangles"]), out["dot"]])
return out
## A file name made into a library name: printable, no ':' (it is part of a state key),
## at most 64 characters.
static func _model_name_from(base: String) -> String:
var s := ""
for ch in base:
var c := ch.unicode_at(0)
if c >= 32 and ch != ":" and ch != "/" and ch != "\\":
s += ch
s = s.strip_edges().substr(0, 64)
return s if s != "" else "model"
## DELETE objects: one object.remove each, ONE gesture ("Delete 3 objects"), so one Ctrl+Z
## brings them all back with everything they had. Returns how many were removed.
## DELETE ACTS ON THE SELECTION (2026-10-05): selected objects, else selected brushes,
## else — nothing selected — the aimed brush. The key used to go straight to
## _delete_aimed(), which never read the selection, so the brush you had clicked was not
## what Delete removed. The outliner calls this too. Returns how many things went.
func delete_selection() -> int:
# THE UNION (§17.10): everything held goes in ONE gesture. Brushes FIRST — their ids are
# only good until the next fold, and a deleted cable takes its curve with it (so a curve
# selected beside its cable is already gone, not refused as "used").
var kinds := int(not _sel_brushes.is_empty()) + int(not _sel_objects.is_empty()) + int(not _sel_curves.is_empty())
if kinds > 1 or _sel_curves.size() > 1:
_ensure_persistence()
var brushes: Array = _sel_brushes.duplicate()
var objs: Array = _sel_objects.duplicate()
var curves: Array = _sel_curves.duplicate()
_persist.begin_gesture("Delete")
var n := 0
if not brushes.is_empty():
n += delete_brushes(brushes)
if not objs.is_empty():
n += delete_objects(objs)
var refused: Array = []
for cd: String in curves:
if not curve_states().has(cd):
continue # went with its cable above
if delete_curve(cd) == 0:
refused.append(_last_refusal)
_persist.end_gesture()
_clear_face_selection()
_last_refusal = "; ".join(refused)
return n
if _sel_curve != "":
return delete_curve(_sel_curve)
if not _sel_objects.is_empty():
return delete_objects(_sel_objects.duplicate())
if not _sel_brushes.is_empty():
return delete_brushes(_sel_brushes.duplicate())
_delete_aimed()
return 0
## Delete brushes by id, ONE gesture. A piece of a live parametric shape deletes the
## WHOLE shape (shape.remove — its pieces are read-only, docs/parametric-shapes-design.md
## §7); every other brush gets a brush.remove. Ctrl+Z brings it all back.
func delete_brushes(ids: Array) -> int:
_ensure_persistence()
var shapes: Dictionary = _persist.live_shapes()
var shape_targets := {}
var n := 0
_persist.begin_gesture("Delete")
var asset_targets := {}
for id: Variant in ids:
var g: String = String(level.get_brush_group(int(id)))
if shapes.has(g):
shape_targets[g] = true
continue
# A PLACED ASSET's piece deletes the placement (#120 L4): its pieces are read-only.
var pa: String = String(_persist.instance_of(g)) if g != "" else ""
if pa != "":
asset_targets[pa] = true
continue
var bkey: String = String(level.get_brush_key(int(id)))
if bkey != "" and _persist.record("brush.remove", {"brush_key": bkey}) != "":
n += 1
for g: String in shape_targets:
if _persist.remove_shape(g) != "":
n += 1
for pa2: String in asset_targets:
if _persist.record("asset.remove", {"target": pa2}) != "":
n += 1
_persist.end_gesture()
if n > 0:
_clear_face_selection()
_persist.refold()
print("[MapDCLevel] deleted %d brush(es) / shape(s) / placed asset(s) — Ctrl+Z to bring them back" % n)
return n
## X KEEPS THE CHILDREN (#120 F1, docs/node-tree-and-stacks.md §2.4): each deleted
## object's direct children (those not being deleted too) first move up to its nearest
## surviving ancestor, keeping their place in the world, so the fold's cascade (an
## object.remove takes its children, SceneObjects) finds none. The cascade itself is left
## as it is — it is what every journal written so far means. `hierarchy` (Delete
## Hierarchy) skips the move-up: the children go too. One gesture either way.
func delete_objects(dots: Array, hierarchy: bool = false) -> int:
_ensure_persistence()
# A PLACED ASSET's object deletes the placement (#120 L4), never the object alone.
var assets := {}
var own: Array = []
for d0: Variant in dots:
var pa := String(_persist.instance_of(String(d0)))
if pa != "":
assets[pa] = true
else:
own.append(d0)
if not assets.is_empty():
_persist.begin_gesture("Delete")
for pa3: String in assets:
_persist.record("asset.remove", {"target": pa3})
if not own.is_empty():
delete_objects(own, hierarchy)
_persist.end_gesture()
_clear_face_selection()
_persist.refold()
return assets.size() + own.size()
var n := 0
_persist.begin_gesture("Delete %d object%s" % [dots.size(), "" if dots.size() == 1 else "s"])
if not hierarchy and _objects != null and is_instance_valid(_objects):
var going := {}
for d: Variant in dots:
going[String(d)] = true
var moves := {} # new parent -> [children]
for e: Dictionary in (_objects.entries() as Array):
var cd := String(e["dot"])
var pd := String(e.get("parent", ""))
if going.has(cd) or not going.has(pd):
continue
var up := pd
while up != "" and going.has(up):
up = object_parent(up)
if not moves.has(up):
moves[up] = []
(moves[up] as Array).append(cd)
for up: String in moves:
parent_objects(moves[up], up, "Delete")
for d: Variant in dots:
if object_node(String(d)) == null:
continue
if _persist.record_at(_persist.reserve_dot(), "object.remove", {"target": String(d)}):
n += 1
_persist.end_gesture()
if n > 0:
_clear_face_selection()
_persist.refold()
print("[MapDCLevel] deleted %d object(s) — Ctrl+Z to bring them back" % n)
return n
## MOVE several objects at once ({dot: Transform3D}, absolute, SCENE space): one
## object.transform each, ONE gesture and ONE refold — what group G / R / O confirm with.
## Each target is written in its PARENT's space (object.parent), so the tools never need
## to know about the hierarchy. A top-level object's scene transform IS its own.
func move_objects(moves: Dictionary, label: String = "") -> int:
_ensure_persistence()
var n := 0
_persist.begin_gesture(label)
for d: String in moves:
if String(_persist.instance_of(d)) != "":
continue # a placed asset's object (#120 L4): read-only
var local: Transform3D = _objects.local_for(_objects.parent_of(d), moves[d] as Transform3D) \
if _objects != null and is_instance_valid(_objects) else moves[d] as Transform3D
if _persist.record_at(_persist.reserve_dot(), "object.transform",
{"target": d, "xform": SceneObjects.xform_to(local)}):
n += 1
_persist.end_gesture()
if n > 0:
_persist.refold()
return n
## PARENT objects (demo sprint §3): one object.parent each, ONE gesture. `parent` "" =
## the top level. Each child keeps its world place (its xform is worked out in the new
## parent's space). Refused per child: the parent itself, a cycle (the parent is the
## child or below it), a bot as a child, a child already there. Returns how many moved.
func parent_objects(dots: Array, parent: String, label: String = "") -> int:
if _objects == null or not is_instance_valid(_objects):
return 0
if parent != "" and object_node(parent) == null:
return 0
_ensure_persistence()
var n := 0
_persist.begin_gesture(label if label != "" else ("Clear parent" if parent == "" else "Parent"))
for v: Variant in dots:
var d := String(v)
var node := object_node(d) as Node3D
if node == null or d == parent or _objects.parent_of(d) == parent:
continue
if parent != "" and (_objects.is_ancestor(d, parent)
or SceneObjects.NEVER_CHILD.has(String(node.get_meta("object_type", "")))):
continue
var local: Transform3D = _objects.local_for(parent, _objects.scene_xform(node))
if _persist.record_at(_persist.reserve_dot(), "object.parent",
{"target": d, "parent": parent, "xform": SceneObjects.xform_to(local)}):
n += 1
_persist.end_gesture()
if n > 0:
_persist.refold()
print("[MapDCLevel] %s %d object(s)%s" % ["unparented" if parent == "" else "parented", n,
"" if parent == "" else " to " + parent])
return n
## The parent's dot, or "" (top level / not an object).
func object_parent(dot: String) -> String:
return _objects.parent_of(dot) if _objects != null and is_instance_valid(_objects) else ""
## An object's transform in SCENE space — what the tools read and write. Same as its own
## transform for a top-level object.
func object_xform(dot: String) -> Transform3D:
var n := object_node(dot) as Node3D
if n == null:
return Transform3D.IDENTITY
return _objects.scene_xform(n)
## Live preview: put an object at scene-space `t` without journalling (tools; cancel
## puts the start back the same way).
func set_object_xform_live(dot: String, t: Transform3D) -> void:
var n := object_node(dot) as Node3D
if n != null:
n.transform = _objects.local_for(_objects.parent_of(dot), t)
## `dots` minus any whose ancestor is also in `dots`: a selected child already moves
## with its selected parent, and moving it as well would move it twice.
func transform_roots(dots: Array) -> Array:
if _objects == null or not is_instance_valid(_objects):
return dots.duplicate()
var out: Array = []
for v: Variant in dots:
var d := String(v)
var p: String = _objects.parent_of(d)
var covered := false
while p != "":
if dots.has(p):
covered = true
break
p = _objects.parent_of(p)
if not covered:
out.append(d)
return out
## The selected object's effective properties ({} when none is selected).
func object_props(dot: String) -> Dictionary:
if _objects == null or not is_instance_valid(_objects):
return {}
return _objects.props(dot)
## The selected object's dot, or "". Objects and brushes are separate selections
## on purpose: they are moved by different records and a single slot would make
## "what does G move" depend on which was clicked last.
var _sel_object: String = ""
## Every selected object, in the order picked; the last is _sel_object (active).
var _sel_objects: Array[String] = []
## Select a created object by its durable dot. Clears the brush selection, so the
## HUD and G agree about what is selected rather than each reading a different
## slot.
## Emitted (deferred, so the selection is complete) whenever what is selected changes:
## the Outliner follows it, so a click in the scene shows up there.
signal selection_changed
func _announce_selection() -> void:
if not _sel_announce_queued:
_sel_announce_queued = true
_emit_selection.call_deferred()
var _sel_announce_queued := false
func _emit_selection() -> void:
_sel_announce_queued = false
_refresh_shape_handles()
selection_changed.emit()
## SELECT OBJECTS. `add` (Shift-click) toggles `dot` in the selection instead of
## replacing it — the brush rule. The ACTIVE object (_sel_object, what the Object panel
## and the Outliner's fields edit) is the last one added; G / R / Delete act on all.
func select_object(dot: String, add: bool = false) -> void:
# A PLACED ASSET's object selects the placement (#120 L4): it is not editable alone.
if dot != "" and _persist != null and String(_persist.instance_of(dot)) != "":
select_asset(String(_persist.instance_of(dot)))
return
if add and dot != "":
var at := _sel_objects.find(dot)
if at >= 0:
_sel_objects.remove_at(at)
else:
_sel_objects.append(dot)
# THE UNION (§17.10): Shift keeps the brushes and curves already held.
_sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
_announce_selection()
_update_face_highlight()
return
else:
_sel_objects.clear()
if dot != "":
_sel_objects.append(dot)
_select_objects_common()
## Replace the object selection with `dots` (the Outliner's multi-row pick); `active`
## becomes the active one when it is among them.
func select_objects(dots: Array, active: String = "") -> void:
# A placed asset's objects are not selectable alone (#120 L4): dropped, and when they
# are all there is, their placement is selected instead.
var placed := ""
if _persist != null:
var own: Array = []
for d0: Variant in dots:
var pa := String(_persist.instance_of(String(d0)))
if pa == "":
own.append(d0)
elif placed == "":
placed = pa
if own.is_empty() and placed != "":
select_asset(placed)
return
dots = own
_sel_objects.clear()
for d: Variant in dots:
if String(d) != "" and not _sel_objects.has(String(d)):
_sel_objects.append(String(d))
if active != "" and _sel_objects.has(active):
_sel_objects.erase(active)
_sel_objects.append(active)
_select_objects_common()
func selected_objects() -> Array:
return _sel_objects.duplicate()
func _select_objects_common() -> void:
_sel_shape = ""
_sel_asset = ""
_sel_curve = ""
_sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
_announce_selection()
_sel_brushes.clear()
_sel_brush = -1
_sel_keys = PackedStringArray()
_sel_kind = "object" if _sel_object != "" else ""
_update_face_highlight()
_update_element_highlight()
_ensure_brush_view().set_aimed(-1)
print("[MapDCLevel] selected %d object(s), active %s" % [_sel_objects.size(), _sel_object])
## The nearest scene object along the crosshair (or click) ray: {dot, dist, point}.
func _pick_object(at: Variant = null, only_type: String = "") -> Dictionary:
if _objects == null or not is_instance_valid(_objects):
return {}
var ray := _crosshair_ray(at)
if ray.is_empty():
return {}
return _objects.pick(ray["from"], ray["dir"], _aim_reach(), only_type)
## HOW FAR THE AIM REACHES (#109, 2026-10-12: "a distance limit to ray based selection,
## needs to be much bigger" — and "larger ray hits for edit mode ONLY, the carve limits
## in game should stay the same"). PLAY_REACH is the 50 m every aim had since the first
## K-carve commit; play mode and carving keep it. In build mode the reach is the camera's
## far plane: what is drawn can be selected (gate_pick_range).
const PLAY_REACH := 50.0
func _aim_reach() -> float:
if not GameMode.is_building():
return PLAY_REACH
var vp := get_viewport()
var cam := vp.get_camera_3d() if vp != null else null
return maxf(cam.far, PLAY_REACH) if cam != null else PLAY_REACH
## The node for a selected dot, or null.
func object_node(dot: String) -> Node:
if _objects == null or not is_instance_valid(_objects):
return null
return _objects.node_for(dot)
## Where a new object should go: on the surface you are aiming at, or a few
## metres in front of you when you are aiming at nothing.
func object_spawn_transform() -> Transform3D:
var hit: Dictionary = _crosshair_hit()
if not hit.is_empty():
return Transform3D(Basis(), hit["position"] as Vector3)
var ray: Dictionary = _crosshair_ray()
if ray.is_empty():
return Transform3D.IDENTITY
return Transform3D(Basis(), (ray["from"] as Vector3) + (ray["dir"] as Vector3) * 4.0)
func _ensure_persistence() -> void:
if _persist != null and is_instance_valid(_persist):
return
_persist = LevelPersistence.new()
_persist.name = "LevelPersistence"
_persist.level = level
_persist.objects = _ensure_objects()
_persist.folded.connect(func(st: Dictionary) -> void:
# The wireframe is every brush's edges: unchanged when the brush half of the fold
# was skipped (refold's skip rule), and rebuilding it was ~half of an object
# edit's remaining cost at 1000 brushes.
if String(st.get("brush_fold", "")) != "skipped":
_refresh_brush_view()
_reselect_shape() # a shape edit renamed its pieces
# The tree is DERIVED, so an edit changes it: creating an arch adds a
# clearance volume, deleting a brush empties part of a collection. A panel
# that rebuilt only when clicked would be showing last import's level.
# The selected curve went with this fold (undo, a peer's delete): let go of it.
if _sel_curve != "" and not (_persist.curve_states() as Dictionary).has(_sel_curve):
_clear_face_selection()
if _outliner != null and is_instance_valid(_outliner):
_outliner.refresh()
_refresh_curves()
_refresh_shape_handles() # the selected shape may have a new size
# Bots must route around what is there NOW (#104 leftover: the nav graph was
# built once per map entry and never again — bots walked through new walls).
_queue_nav_rebuild())
_register_persistence_admin(_persist)
add_child(_persist)
# The asset libraries (#120 L4): with the journal, so placed assets' blobs are fetched
# whatever panel is open.
_asset_library = AssetLibraryScript.new()
_asset_library.name = "AssetLibrary"
add_child(_asset_library)
_asset_library.setup(self, _persist)
# ---- navigation (bots) --------------------------------------------------------
#
# EVERY LEVEL MAP ROUTES BOTS. This lived in map_flat alone, so the STARTING map
# (map_test, also a map_dc_level) had no nav source at all: the A* brain logged
# "no nav source" and stood still, and bots walked through walls (user, 2026-10-04).
# DCNavVolume is the libdc region/portal graph fed from plain boxes (dc-terrain
# docs/plans/2026-09-15-chunk-portal-nav-design.md §11) -- no density field needed.
#
# The solid set: every axis-aligned BoxShape3D under the map (the scene's own
# colliders and placed objects' bodies) + every solid brush as its world bounds
# (exact for box brushes; a sloped brush is over-approximated -- a bot keeping clear
# of it, never walking into it).
var _nav: Node = null
## Cells per chunk axis and their size. 1 m cells fit a 0.4 m agent radius.
const NAV_CELLS := 16
const NAV_CELL_SIZE := 1.0
## Cap on the graph's cube edge; a larger map overrides _nav_region.
const NAV_MAX_SPAN := 256.0
const NAV_AGENT_RADIUS := 0.4
const NAV_AGENT_HEIGHT := 1.8
func _ensure_nav() -> Node:
if _nav != null and is_instance_valid(_nav):
return _nav
if not ClassDB.class_exists("DCNavVolume"):
push_warning("[MapDCLevel] DCNavVolume missing — the dc-terrain extension is stale")
return null
_nav = ClassDB.instantiate("DCNavVolume")
_nav.name = "NavVolume"
add_child(_nav)
return _nav
## Every BoxShape3D collider under `n`, in world space. Axis-aligned only for now.
func _collect_nav_boxes(n: Node, centres: PackedVector3Array, halves: PackedVector3Array) -> void:
if n is CollisionShape3D:
var cs := n as CollisionShape3D
if cs.shape is BoxShape3D and not cs.disabled:
var t := cs.global_transform
var b := t.basis
var axis_aligned := (
absf(b.x.normalized().dot(Vector3.RIGHT)) > 0.999
and absf(b.y.normalized().dot(Vector3.UP)) > 0.999)
if axis_aligned:
centres.push_back(t.origin)
halves.push_back((cs.shape as BoxShape3D).size * 0.5 * b.get_scale())
for c in n.get_children():
_collect_nav_boxes(c, centres, halves)
## Every SOLID brush as its world-space bounds. Brush colliders are PhysicsServer
## shapes, not CollisionShape3D nodes, so the walk above never sees them. Sky,
## clearance and hidden brushes are not solid for a walker. Brush vertices are in the
## store's space, which is the DCLevel node's (the brush view is parented to it).
func _collect_brush_boxes(centres: PackedVector3Array, halves: PackedVector3Array) -> void:
if level == null or not level.has_method("get_live_brush_ids"):
return
var skip := {}
for id in level.get_sky_brush_ids():
skip[id] = true
for id in level.get_clearance_brush_ids():
skip[id] = true
for id in level.get_hidden_brush_ids():
skip[id] = true
var xf: Transform3D = level.global_transform
var live: PackedInt32Array = level.get_live_brush_ids()
# ONE CALL FOR EVERY BOX: the store already holds each brush's bounds. This ran after
# every fold, one get_brush_vertices_of per brush — 22 ms of a 28 ms nav rebuild at 40
# cables on tabertron (2026-10-20). The bounds are the windings' extent; the vertex
# walk below took the same over 1e-3-deduplicated corners (_probe_cut_refolds PROBE_NAV
# compares the two).
if level.has_method("get_brush_aabbs"):
var bx: PackedVector3Array = level.get_brush_aabbs(live)
for i in live.size():
if skip.has(live[i]):
continue
var lo: Vector3 = bx[i * 2]
var hi: Vector3 = bx[i * 2 + 1]
if lo.x > hi.x:
continue # no bounds: encloses nothing
var wb: AABB = xf * AABB(lo, hi - lo)
centres.push_back(wb.get_center())
halves.push_back(wb.size * 0.5)
return
for id in live:
if skip.has(id):
continue
var vs: PackedVector3Array = level.get_brush_vertices_of(id)
if vs.is_empty():
continue
var bb := AABB(xf * vs[0], Vector3.ZERO)
for v in vs:
bb = bb.expand(xf * v)
centres.push_back(bb.get_center())
halves.push_back(bb.size * 0.5)
## The box the graph covers: the bounds of everything solid, padded so an agent can
## stand on the highest of it. NOT a cube (DCNavVolume.rebuild_box): a level 200 m
## across and 15 m tall cubed to 208 m measured 3.1 s per build. Each horizontal edge
## is capped at NAV_MAX_SPAN about the centre.
func _nav_region(centres: PackedVector3Array, halves: PackedVector3Array) -> AABB:
if centres.is_empty():
return AABB()
var bb := AABB(centres[0] - halves[0], halves[0] * 2.0)
for i in range(1, centres.size()):
bb = bb.merge(AABB(centres[i] - halves[i], halves[i] * 2.0))
bb = bb.grow(2.0)
bb.size.y += NAV_AGENT_HEIGHT
for ax in [0, 2]:
if bb.size[ax] > NAV_MAX_SPAN:
push_warning("[MapDCLevel] nav: the map spans %.0f m on axis %d; the graph covers the central %.0f m (override _nav_region)"
% [bb.size[ax], ax, NAV_MAX_SPAN])
bb.position[ax] += (bb.size[ax] - NAV_MAX_SPAN) * 0.5
bb.size[ax] = NAV_MAX_SPAN
return bb
## Rebuild the graph from what is in the map right now. Returns populated chunks.
## THE NAV GRAPH FOLLOWS THE LEVEL. Every fold (a brush or object edit, an undo, a
## peer's edit arriving) marks it dirty and restarts a NAV_REBUILD_DELAY timer, so a
## burst of edits costs one rebuild after the burst, not one per keypress (~0.1 s at
## 2000 brushes since dc-terrain ad3024f). nav_rebuild clears the flag — the build that
## runs on map entry clears the load's own folds, so a load still builds once.
const NAV_REBUILD_DELAY := 1.0
var _nav_dirty := false
var _nav_timer: Timer = null
var nav_builds := 0
func _queue_nav_rebuild() -> void:
_nav_dirty = true
if _nav_timer == null or not is_instance_valid(_nav_timer):
_nav_timer = Timer.new()
_nav_timer.name = "NavRebuildTimer"
_nav_timer.one_shot = true
_nav_timer.timeout.connect(func() -> void:
if _nav_dirty:
nav_rebuild())
add_child(_nav_timer)
if _nav_timer.is_inside_tree():
_nav_timer.start(NAV_REBUILD_DELAY)
func nav_rebuild() -> int:
_nav_dirty = false
nav_builds += 1
var v := _ensure_nav()
if v == null:
return 0
var centres := PackedVector3Array()
var halves := PackedVector3Array()
_collect_nav_boxes(self, centres, halves)
var node_boxes := centres.size()
_collect_brush_boxes(centres, halves)
var region := _nav_region(centres, halves)
if region.size == Vector3.ZERO:
return 0
v.set_boxes(centres, halves)
var populated: int = v.rebuild_box(region, NAV_CELLS * NAV_CELL_SIZE, NAV_CELLS,
NAV_AGENT_RADIUS, NAV_AGENT_HEIGHT)
var st: Dictionary = v.stats()
print("[MapDCLevel] nav: %d boxes + %d brushes over %s m -> %d chunks, %d links, %d regions, %d standable"
% [node_boxes, centres.size() - node_boxes, str(region.size.round()), populated,
int(st["links"]), int(st["regions"]), int(st["standable"])])
# Durably: print() goes to stdout, which a dedicated peer's TUI swallows.
if GameState != null:
GameState.set_state("state:map:nav:boxes", centres.size())
GameState.set_state("state:map:nav:chunks", populated)
GameState.set_state("state:map:nav:links", int(st["links"]))
GameState.set_state("state:map:nav:standable", int(st["standable"]))
return populated
func get_nav_volume() -> Node:
return _nav
## Expose the level journal on the AdminFacade so a builder bot -- or the TUI, or
## admin-cli -- can append, inspect and roll back edits. This is `bot.edit` from
## spec/bot-testbed.md (e3) with nothing invented: record/undo_op/records are the
## existing API, and the operator stack is already the vocabulary.
##
## REGISTERED HERE, NOT IN level_persistence.gd's _ready, so only the LIVE
## instance is a control surface. Gates build bare LevelPersistence nodes as
## fixtures (level_journal_roundtrip_gate.gd makes two), and registering in _ready
## would put test fixtures in the admin_controllable group and collide their
## facade paths.
##
## SCOPE IS MAP (the default, not "global"). The journal belongs to the level it
## journals, and it is a child of this map, so it dies with the map -- which is
## the containment rule from (e1) rather than an accident.
##
## ⚠ EVERY SCOPE IS "authority", AND HERE THAT IS LOAD-BEARING. For
## PerfMetrics.snapshot, "local" merely returned an arbitrary peer's numbers
## because results share one admin:result:<seq> key. For the journal it is far
## worse: an admin:cmd reaches EVERY peer, records are AUTHOR-SCOPED, and each
## peer would reserve its own dot -- so one "create a box" command would append N
## separate records under N authors and build the same brush N times. "authority"
## means one peer records it, which is also exactly the "the room owns a builder
## bot's edits" model: the edit is authored by whoever holds authority.
##
## Roles split read from write. Reads are "peer" -- inspecting the level you are
## standing in is not privileged. Writes are "mod": a room-owned builder bot is
## privileged world authoring, the same judgement world:creatures:registry: makes
## by carrying role mod.
##
## `by_bot` is a RESERVED PAYLOAD KEY, not a parameter. record_at() does
## payload.duplicate() and adds only v/kind/undone/src, so an extra key is stored
## verbatim -- no change to LevelPersistence is needed. records() can then filter
## on it, and undo_op(dot) rolls back one record, which together give
## bulk-undo-of-a-bot's-work with no new mechanism. Until the bot registry exists
## the caller supplies it; the registry will stamp it.
func _register_persistence_admin(p: Node) -> void:
p.add_to_group("admin_controllable")
p.set_meta("admin_name", "level")
p.set_meta("admin_exposed_methods", [
"record", "undo_op", "redo_op", "undo_last", "redo_last",
"records", "undo_depth", "redo_depth",
])
p.set_meta("admin_call_scopes", {
"record": "authority", "undo_op": "authority", "redo_op": "authority",
"undo_last": "authority", "redo_last": "authority",
"records": "authority", "undo_depth": "authority", "redo_depth": "authority",
})
p.set_meta("admin_roles", {
"records": {"call": "peer"},
"undo_depth": {"call": "peer"},
"redo_depth": {"call": "peer"},
"record": {"call": "mod"},
"undo_op": {"call": "mod"},
"redo_op": {"call": "mod"},
"undo_last": {"call": "mod"},
"redo_last": {"call": "mod"},
})
## ONE KEY = ONE GESTURE (editor redesign E5). Whatever a builder key writes — a CSG op's
## records, a texture on six faces, an operator's confirm — shares one gesture id, so one
## Ctrl+Z takes it back. A key that writes nothing makes no gesture (the id is minted by the
## first record). Undo itself writes tombstones, not records, so it is not a gesture.
func _unhandled_key_input(event: InputEvent) -> void:
if level == null or not (event is InputEventKey) or event.echo:
return
var p: Node = _persist if _persist != null and is_instance_valid(_persist) else null
if p != null:
p.begin_gesture()
_builder_key(event)
if p != null:
p.end_gesture()
func _builder_key(event: InputEvent) -> void:
# A key, or a KEYLESS action from Keymap.run (an InputEventAction: the Q menu's
# rows, Subtract since §9c) — see _unhandled_input.
if level == null or not (event is InputEventKey or event is InputEventAction) \
or (event is InputEventKey and event.echo):
return
# Box select (B) is waiting for a drag: Esc cancels it.
if _box_sel and event is InputEventKey and (event as InputEventKey).keycode == KEY_ESCAPE:
_box_sel = false
_box_show(Rect2())
get_viewport().set_input_as_handled()
return
# Circle select (C): Esc / Enter leave it; [ ] resize the ring.
if _circ_sel and event is InputEventKey and (event as InputEventKey).pressed:
var ck: int = KeyRules.key_of(event as InputEventKey)
if ck in [KEY_ESCAPE, KEY_ENTER, KEY_KP_ENTER]:
end_circle_select()
get_viewport().set_input_as_handled()
return
if ck == KEY_BRACKETRIGHT or ck == KEY_BRACKETLEFT:
_circ_radius = clampf(_circ_radius * (1.15 if ck == KEY_BRACKETRIGHT else 1.0 / 1.15), CIRC_MIN, CIRC_MAX)
_circ_show()
get_viewport().set_input_as_handled()
return
_note_repeatable(event) # Shift+R repeats what was last started here
# EVERY BINDING HERE IS A NAMED ACTION IN THE `builder` CONTEXT (autoloads/
# keymap.gd), not a keycode comparison. Three things that buys, all of which
# the keymap audit found the hard way:
# - the bindings are ENUMERABLE, so a conflict is reportable instead of
# something you grep for by hand and get wrong (Ctrl+Z shipped on top of
# toggle_slowmo, which is bound to plain Z);
# - Keymap.tapped matches EXACTLY, so an action on a plain key cannot fire
# when a modifier is held — Godot's default does the opposite;
# - they are rebindable and context-scoped, which is what lets `B` mean one
# thing while building and another while walking.
_ensure_persistence()
# THE BOT EDITOR has no brush tools: their keys do nothing there (G / R / Delete /
# undo / select keep working — on the selected bots).
if in_bot_editor():
for a: String in LEVEL_ONLY_ACTIONS:
if Keymap.tapped(event, a):
return
# THE ACTIVE OPERATOR GETS FIRST REFUSAL — which is what this always claimed and
# was not. It sat BELOW the selection nudge, and `[` / `]` are bound there
# (builder.move_in / move_out), so those two keys were consumed and returned
# before any operator saw them: the Knife's offset, Move's steps, Extrude's push
# and Box's resize were ALL dead in the running game. Every one of them was gated
# by calling handle_key directly, which is the seam issue #23 named — the gate
# proved the operator does the right thing when told, never that it gets told.
#
# Above everything, not merely above the nudge: while a tool is up its keys are
# ITS keys, and that is what makes it modal. Anything it does not claim still
# falls through — including movement, or flying with the knife out would strand
# you — and the same keys mean the selection nudge again the moment it is gone.
var ops := _ensure_ops()
if ops.handle_key(event):
_update_op_hud()
# AND NOTHING ELSE GETS IT. Nothing here marked input handled, so a key the
# map acted on ALSO reached the player's _unhandled_input — which is why the
# Knife's `X` axis key toggles the flashlight (toggle_light is bound to X).
# Same class as Ctrl+Z also firing toggle_slowmo, and the same fix: claimed
# means claimed.
var vp := get_viewport()
if vp != null:
vp.set_input_as_handled()
return
if event is InputEventKey and (event as InputEventKey).keycode == KEY_ESCAPE and ops.handle_escape():
# First-wins: an active operator eats Escape; otherwise it goes on to mean
# release-mouse / menu exactly as it always did.
#
# MARKED HANDLED, which it was not: the player releases the mouse on
# `ui_cancel` from its own _unhandled_input, so cancelling a tool ALSO threw
# you out of mouse capture. First-wins means nothing else gets it.
print("[MapDCLevel] operator cancelled — nothing was written")
_update_op_hud()
var esc_vp := get_viewport()
if esc_vp != null:
esc_vp.set_input_as_handled()
return
if Keymap.tapped(event, "builder.confirm"):
ops.confirm()
return
if Keymap.tapped(event, "builder.tool_popup") and _tool_popup != null:
var tools: Array = []
for t: Array in load("res://scenes/game/editor_tool_bar.gd").TOOLS:
tools.append([t[0], t[1], t[2]])
var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_tool_popup.open_sections([["Tools", tools]], at)
var tp_vp := get_viewport()
if tp_vp != null:
tp_vp.set_input_as_handled()
return
# SHIFT+S = THE SNAP MENU, WHEN NOT MOVING (#109, user's choice: Shift+S is also fast
# fly-back). The S of this press is the menu's; any OTHER movement key already held
# means you are flying, and then Shift+S stays fly-back.
if Keymap.tapped(event, "builder.q_menu") and _q_menu != null:
var qat := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_q_menu.open_sections(q_sections(), qat)
var q_vp := get_viewport()
if q_vp != null:
q_vp.set_input_as_handled()
return
for hp: Array in [["builder.hide", "hide_selection"], ["builder.hide_unselected", "hide_unselected"],
["builder.unhide_all", "unhide_all"], ["builder.make_real", "_q_make_real"],
["builder.cuts_clear", "clear_cuts"], ["builder.select_cut_target", "select_cut_target"],
["builder.extrude_make_real", "_q_extrude_make_real"], ["builder.prompt_cancel", "_q_prompt_cancel"],
["builder.make_real_then", "_q_make_real_then"], ["builder.place_asset", "place_current_asset"]]:
if Keymap.tapped(event, String(hp[0])):
call(String(hp[1]))
return
for kp: Array in [["builder.cut_as_subtract", "subtract"], ["builder.cut_as_intersect", "intersect"],
["builder.cut_as_slice", "slice"]]:
if Keymap.tapped(event, String(kp[0])):
set_cutter_kind(stack_subject(), String(kp[1]))
return
if Keymap.tapped(event, "builder.cursor_menu") and _context_menu != null and not _moving_except_back():
var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_context_menu.open_sections([cursor_section()], at)
var cm_vp := get_viewport()
if cm_vp != null:
cm_vp.set_input_as_handled()
return
if Keymap.tapped(event, "builder.palette") and _palette != null:
_palette.open()
var pal_vp := get_viewport()
if pal_vp != null:
pal_vp.set_input_as_handled()
return
# Fill is the one HELD interaction — aim, hold, aim, release — so it needs the
# key-up that every other binding discards.
if Keymap.tapped(event, "builder.fill"):
_fill_begin()
return
if Keymap.released(event, "builder.fill"):
_fill_commit()
return
# Universal undo / redo, over every edit I authored in this room and map,
# including previous sessions: it queries the journal rather than a stack.
if Keymap.tapped(event, "builder.redo"):
_undo_redo(true)
return
if Keymap.tapped(event, "builder.undo"):
_undo_redo(false)
return
# Face / edge / vertex editing. Separate keys from carving because they are
# different operations on different things: a carve leaves the brush
# authoritative and derives a voxel pocket, a nudge MUTATES the brush and
# produces no model at all. Conflating them would hide the architecture's fork.
if Keymap.tapped(event, "builder.deselect"):
_clear_face_selection()
return
if Keymap.tapped(event, "builder.select"):
_pick_element()
return
# ---- KEYMAP STEP 3 (docs/keymap-blender-279.md §3a) ----
if Keymap.tapped(event, "builder.select_all"):
# 2.79's A with use_select_all_toggle: anything selected -> none, else ALL —
# objects, brushes and curves together (§17.10).
select_all()
return
if Keymap.tapped(event, "builder.select_invert"):
select_invert()
return
if Keymap.tapped(event, "builder.select_box"):
begin_box_select()
return
if Keymap.tapped(event, "builder.select_circle"):
begin_circle_select()
return
# Ctrl+Tab: Blender's select-mode menu — the same actions as 1 2 3 4, keys shown.
if Keymap.tapped(event, "builder.select_mode_menu") and _q_menu != null:
var sm_at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
_q_menu.open_sections([["Select mode", [["build_mode.sel_vertex"], ["build_mode.sel_edge"],
["build_mode.sel_face"], ["build_mode.sel_brush"]]]], sm_at)
var sm_vp := get_viewport()
if sm_vp != null:
sm_vp.set_input_as_handled()
return
if Keymap.tapped(event, "builder.select_linked_pick"):
select_linked_pick(false)
return
if Keymap.tapped(event, "builder.deselect_linked_pick"):
select_linked_pick(true)
return
if Keymap.tapped(event, "builder.select_linked"):
select_linked()
return
if Keymap.tapped(event, "builder.delete_menu") and _q_menu != null:
_q_menu.open_sections(delete_sections(), get_viewport().get_mouse_position())
get_viewport().set_input_as_handled()
return
if Keymap.tapped(event, "builder.select_grouped") and _q_menu != null:
_q_menu.open_sections(grouped_sections(), get_viewport().get_mouse_position())
get_viewport().set_input_as_handled()
return
for sg: Array in [["builder.select_same_group", "group"], ["builder.select_same_material", "material"],
["builder.select_same_texture", "texture"], ["builder.select_same_type", "type"]]:
if Keymap.tapped(event, String(sg[0])):
select_grouped(String(sg[1]))
return
if Keymap.tapped(event, "builder.add_menu") and _q_menu != null:
_q_menu.open_sections(add_sections(), get_viewport().get_mouse_position())
get_viewport().set_input_as_handled()
return
for shape_id: String in ADD_SHAPES:
if Keymap.tapped(event, "builder.add_%s" % shape_id):
start_create(shape_id)
return
if Keymap.tapped(event, "builder.uv_menu") and _q_menu != null:
_q_menu.open_sections(uv_sections(), get_viewport().get_mouse_position())
get_viewport().set_input_as_handled()
return
if Keymap.tapped(event, "builder.toggle_sidebar") or Keymap.tapped(event, "builder.toggle_toolbar"):
var side: String = BuildDock.SIDE_RIGHT if Keymap.tapped(event, "builder.toggle_sidebar") else BuildDock.SIDE_LEFT
BuildDock.set_region_collapsed(side, not BuildDock.is_region_collapsed(side))
return
if Keymap.tapped(event, "builder.reset_panels"):
BuildDock.reset_panel_layout()
return
if Keymap.tapped(event, "builder.adjust_last") or Keymap.tapped(event, "builder.history"):
var pid := "adjust_last" if Keymap.tapped(event, "builder.adjust_last") else "history"
if BuildDock.has_panel(pid):
BuildDock._toggle(pid)
else:
print("[MapDCLevel] %s: nothing to show yet" % pid)
return
if Keymap.tapped(event, "builder.repeat_last"):
if _last_repeatable == "":
print("[MapDCLevel] repeat last: nothing to repeat yet")
else:
# BoxCutter repeats with the last cut's modifiers (#120 L2 §13.5).
var bcut := get_node_or_null("BoxCutter")
if _last_repeatable == "builder.box_cutter" and bcut != null:
bcut.restore_last_queue()
Keymap.run(_last_repeatable)
return
if Keymap.tapped(event, "builder.element_toggle"):
set_select_mode(_last_element_mode if _sel_mode == "brush" else "brush")
return
if Keymap.tapped(event, "builder.wireframe"):
toggle_wireframe()
return
for pv: Array in [["builder.pivot_bbox", "bbox"], ["builder.pivot_median", "median"],
["builder.pivot_cursor", "cursor"], ["builder.pivot_active", "active"],
["builder.pivot_individual", "individual"]]:
if Keymap.tapped(event, String(pv[0])):
set_pivot_mode(String(pv[1]))
return
if Keymap.tapped(event, "builder.proportional"):
toggle_proportional()
return
if Keymap.tapped(event, "builder.proportional_falloff"):
cycle_prop_falloff()
return
if Keymap.tapped(event, "builder.proportional_connected"):
toggle_prop_connected()
return
if Keymap.tapped(event, "builder.texture_apply"):
apply_texture_to_selection()
return
if Keymap.tapped(event, "builder.texture_pick"):
eyedrop_texture()
return
if _uv_key(event):
return
if Keymap.tapped(event, "builder.move_in"):
_nudge_element(-_grid_step())
return
if Keymap.tapped(event, "builder.move_out"):
_nudge_element(_grid_step())
return
for pair: Array in [["subtract", "build_mode.csg_subtract"],
["intersect", "build_mode.csg_intersect"],
["slice", "build_mode.csg_slice"],
["merge", "build_mode.csg_merge"]]:
if Keymap.tapped(event, String(pair[1])):
_csg(String(pair[0]))
return
if Keymap.tapped(event, "build_mode.csg_discard"):
csg_discard = not csg_discard
print("[csg] cutters will be %s" % ("DISCARDED" if csg_discard else "KEPT"))
return
if Keymap.tapped(event, "build_mode.faces_hull"):
faces_to_hull()
return
if Keymap.tapped(event, "build_mode.faces_extrude"):
faces_extrude()
return
for mode: String in SEL_MODES:
if not Keymap.tapped(event, "build_mode.sel_%s" % mode):
continue
set_select_mode(mode)
return
if Keymap.tapped(event, "builder.grid_finer") or Keymap.tapped(event, "builder.grid_coarser"):
# NOT the number row: 1-9 are weapon slots in a shooter. TrenchBroom binds
# grid sizes to 1-9 and can afford to; this cannot.
var finer: bool = Keymap.tapped(event, "builder.grid_finer")
_grid_idx = clampi(_grid_idx + (-1 if finer else 1), 0, GRID_UNITS.size() - 1) # GRID_UNITS ascends
print("[MapDCLevel] grid %s (%.3f m)" % [fmt_units(base_grid_units()), base_grid_units() * WORLD_SCALE])
return
if Keymap.tapped(event, "builder.snap_toggle"):
snap_enabled = not snap_enabled
print("[MapDCLevel] snap %s" % ("on" if snap_enabled else "off — 1 unit steps"))
return
# G MOVES THE SELECTION, which is Blender's rule and needs no second key: a
# selected vertex, edge or face if there is one, otherwise the whole brush under
# the crosshair. Both drag with your aim; they differ only in what moves.
# R TURNS THE SELECTION (or the aimed brush) — Blender's key for rotate.
if Keymap.tapped(event, "builder.rotate"):
if ops.activate(_rotate):
print("[MapDCLevel] rotate: X Y Z axis, [ ] or wheel turns, Enter to rotate")
return
# Ctrl+P parents the selected objects to the ACTIVE one; Alt+P clears (Blender).
if Keymap.tapped(event, "builder.parent"):
var others := selected_objects()
others.erase(_sel_object)
if _sel_object == "" or others.is_empty():
print("[MapDCLevel] parent: select the children, then the parent last (it is the active one)")
else:
parent_objects(others, _sel_object)
return
if Keymap.tapped(event, "builder.clear_parent"):
if selected_objects().is_empty():
print("[MapDCLevel] clear parent: select objects first")
else:
parent_objects(selected_objects(), "")
return
# OBJECTS AND COLLECTIONS (#120 F1): Ctrl+G, M, Shift+M, Ctrl+Alt+G, Ctrl+J, P.
if _collection_keys(event):
return
# O SCALES the selection (or the aimed brush, or the selected models) — not S, which
# flies backwards in build mode.
if Keymap.tapped(event, "builder.scale"):
if ops.activate(_scale):
print("[MapDCLevel] scale: [ ] or wheel, X Y Z axis, C pivot, Enter to scale")
return
if Keymap.tapped(event, "builder.mirror"):
if ops.activate(_mirror):
print("[MapDCLevel] mirror: X Y Z axis, Enter to mirror, Esc to cancel")
return
if Keymap.tapped(event, "builder.duplicate"):
duplicate_selection()
return
if Keymap.tapped(event, "builder.duplicate_linked"):
duplicate_selection(true)
return
if Keymap.tapped(event, "builder.move"):
var g: LevelOperator = _move
var what := "brush under the crosshair"
# A SELECTED CURVE by select mode (§17.7 Q4): vertex mode edits the point / handle
# end nearest the pointer (Edit curve keeps to the selected curve); any other mode
# moves the whole curve (Move's curve branch).
if _sel_curve != "" and _sel_mode == "vertex":
g = _curve_edit
what = "curve point"
elif _sel_curve != "":
what = "selected curve"
elif _sel_brush >= 0 and _sel_kind == "brush":
what = "selected brush"
elif _sel_brush >= 0 and not _sel_keys.is_empty():
g = _grab_op
what = "selected %s" % _sel_kind
# A shape's part: Make Real first (or Cancel), then the grab on the same part.
if not guard_shape_part("move", func() -> void: _ensure_ops().activate(_grab_op)):
return
if ops.activate(g):
print("[MapDCLevel] %s the %s: look to drag, X/Y/Z axis, [ ] to nudge, Enter, Esc to cancel"
% [g.op_name(), what])
return
# One line per operator, because that is all an operator should cost to add.
for binding: Array in [
["builder.knife", _knife, "aim or place points with P, X/Y/Z axis, [ ] offset, Enter to cut"],
["builder.extrude", _extrude, "[ ] to push the face, Enter to extrude"],
["builder.create_box", _box, "[ ] to resize, Enter to create"],
["builder.box_cutter", get_node_or_null("BoxCutter"), "P corner, X mode, E depth, Space through, Enter to cut"]]:
if not Keymap.tapped(event, String(binding[0])):
continue
if ops.activate(binding[1] as LevelOperator):
print("[MapDCLevel] %s: %s, Esc to cancel"
% [(binding[1] as LevelOperator).op_name(), String(binding[2])])
return
# OVERLAYS (keymap step 6.5): each key flips its overlay's own switch; Shift+Alt+Z the
# master. Counts printed by toggle_overlay.
for ov: String in OVERLAYS:
if Keymap.tapped(event, String((OVERLAYS[ov] as Array)[0])):
toggle_overlay(ov)
return
if Keymap.tapped(event, "builder.overlays_all"):
set_overlays_all(not overlays_all_on())
print("[MapDCLevel] overlays %s" % ("back on" if overlays_all_on() else "all off"))
return
if Keymap.tapped(event, "builder.delete"):
# The SELECTION — objects, then brushes; only with nothing selected the aimed
# brush (delete_selection).
delete_selection()
return
if Keymap.tapped(event, "builder.add_curve"):
if _ensure_ops().activate(_curve_op):
print("[MapDCLevel] curve: P places a point on the aimed surface, C closes, Enter adds it")
return
if Keymap.tapped(event, "builder.add_cable"):
if _ensure_ops().activate(_cable_op):
print("[MapDCLevel] cable: P places a point on the aimed surface, [ ] slack, C closes, Enter adds it")
return
if Keymap.tapped(event, "builder.edit_curve"):
if _ensure_ops().activate(_curve_edit):
print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies")
return
if Keymap.tapped(event, "builder.cable_bundle"):
if _ensure_ops().activate(_bundle_op):
print("[MapDCLevel] cable bundle: Z count, D tension, N jitter, S radius, R ends, Q seed, Enter adds")
return
if Keymap.tapped(event, "builder.cable_from_edges"):
if _ensure_ops().activate(_edges_op):
print("[MapDCLevel] cable from edges: P vertices / centres, C corners, [ ] offset, Enter adds")
return
if Keymap.tapped(event, "builder.drop_cable"):
if _ensure_ops().activate(_drop_op):
print("[MapDCLevel] drop: the selected cables fall to the ground, [ ] point step, Enter drops")
return
if Keymap.tapped(event, "builder.make_parametric"):
make_parametric()
return
if Keymap.tapped(event, "builder.add_modifier"):
if _sel_shape == "":
print("[MapDCLevel] add modifier: select a shape (Make parametric turns a brush into one)")
elif _q_menu != null:
var rows: Array = []
for k: String in MOD_DEFAULTS:
rows.append([k.capitalize(), func() -> void: add_modifier(k), MOD_KEYS.get(k, 0)])
_q_menu.open_choices("Add modifier", rows, get_viewport().get_mouse_position())
return
if Keymap.tapped(event, "builder.stack_scroll_back") or Keymap.tapped(event, "builder.stack_scroll_fwd"):
preload("res://scenes/maps/stack_ops.gd").scroll(self, -1 if Keymap.tapped(event, "builder.stack_scroll_back") else 1)
return
# #120 L2 §13.4–13.5: Change cut / Extract / Apply up to here — each picks one of the
# subject's cuts from a list (StackOps).
for sa: Array in [["builder.change_cut", "Change cut"], ["builder.extract_cut", "Extract"],
["builder.apply_up_to", "Apply up to here"]]:
if not Keymap.tapped(event, String(sa[0])):
continue
var so := preload("res://scenes/maps/stack_ops.gd")
var subj := stack_subject()
var srows: Array = []
match String(sa[0]):
"builder.change_cut":
srows = so.change_rows(self, subj)
"builder.extract_cut":
srows = so.op_rows(self, subj, so.extract)
_:
srows = so.op_rows(self, subj, so.apply_up_to)
if srows.is_empty():
print("[MapDCLevel] %s: the selection has no cuts" % String(sa[1]).to_lower())
elif _q_menu != null:
_q_menu.open_choices(String(sa[1]), srows, get_viewport().get_mouse_position())
return
if Keymap.tapped(event, "builder.assign_generator"):
if _sel_shape == "":
print("[MapDCLevel] assign generator: select a shape (Make parametric turns a brush into one)")
elif _q_menu != null:
_q_menu.open_choices("Generator", generator_choices(), get_viewport().get_mouse_position())
return
if Keymap.tapped(event, "builder.delete_hierarchy"):
if selected_objects().is_empty():
print("[MapDCLevel] delete hierarchy: select objects first")
else:
delete_objects(selected_objects(), true)
return
if not Keymap.tapped(event, "builder.carve"):
return
var hit := _crosshair_hit(null, PLAY_REACH) # carving keeps its reach (#109)
if hit.is_empty():
print("[MapDCLevel] carve: nothing under the crosshair")
return
# Name what was hit. "Nothing happened" with no indication of what the ray
# found is not a diagnosis, and this is the line that would have identified
# the bug above in one keypress instead of a deploy round-trip.
var who: Object = hit.get("collider")
var who_name: String = who.get_class() if who != null else "<null>"
if who is Node:
who_name = "%s (%s)" % [(who as Node).name, who.get_class()]
var before: int = int(level.get_stats().get("triangles", 0))
# The DOT FIRST, then the local apply with that same name. The optimistic local
# state and the journal then agree on identity from the start, so the fold that
# follows is a no-op rather than a re-application under a different id.
var pos: Vector3 = hit["position"]
var dot: String = _persist.record("carve", {
"type": "sphere",
"pos": [pos.x, pos.y, pos.z],
"radius": 0.6,
})
var id: int = level.apply_carve_sphere(pos, 0.6, dot)
if id < 0:
return
level.rebuild()
var stats: Dictionary = level.get_stats()
print("[MapDCLevel] carve %s (local %d) at %s hit=%s -> %s" % [dot, id, str(pos), who_name, str(stats)])
# A carve that touches no brush is a silent no-op by construction: the journal
# accepts it, nothing leaves the clean view, no pocket is built. Say so.
if int(stats.get("triangles", 0)) == before and int(stats.get("pockets", 0)) == 0:
push_warning("[MapDCLevel] carve %d touched NO brush — the hit at %s is not inside any brush AABB (hit %s). Nothing was carved." % [id, str(hit["position"]), who_name])
## MOUSE CONFIRM AND CANCEL, which is how every editor ends a modal transform:
## Blender's G is finished with a left click and abandoned with a right one, and
## TrenchBroom's clip is the same. Enter and Escape still work and always did — this
## is the gesture your hand is already making, on the device that is doing the
## dragging.
##
## ONLY WHILE A TOOL IS UP. With no operator active both buttons mean exactly what
## they meant before, so nothing is taken away from play. And a click that IS
## consumed here is marked handled, so it cannot also reach the player — which used
## to fire the grapple, because left click is `grapple` (that is now context-gated at
## its source as well; both halves are needed, and neither is sufficient alone).
func _unhandled_input(event: InputEvent) -> void:
# THE BASE CLASS FIRST. This override used to swallow the event entirely, so
# every universal key map_core.gd defines -- F6 wireframe (documented there
# as being "on EVERY map"), the Ctrl+F5 ground probe, Ctrl+F8 edit overlay,
# Ctrl+F9 LOD colours -- silently did nothing on this map. An override that
# does not call super is how a base class's feature disappears from exactly
# the maps that need it most.
super._unhandled_input(event)
# KEYLESS ACTIONS. Keymap.run hands an action with no key (the Q menu's rows, CSG
# Subtract since docs/cutting-and-operations.md §9c) over as an InputEventAction,
# and _unhandled_key_input never sees one — so a menu pick of it did nothing.
# The same gesture wrap as a key: one pick, one Ctrl+Z.
if level != null and event is InputEventAction and event.is_pressed():
var gp: Node = _persist if _persist != null and is_instance_valid(_persist) else null
if gp != null:
gp.begin_gesture()
_builder_key(event)
if gp != null:
gp.end_gesture()
return
# BOX SELECT (B) owns the next LMB drag (and motion while dragging).
if level != null and _box_select_input(event):
get_viewport().set_input_as_handled()
return
# CIRCLE SELECT (C) owns the mouse while it is up: paint, resize, leave.
if level != null and _circle_select_input(event):
get_viewport().set_input_as_handled()
return
if level == null or not (event is InputEventMouseButton):
return
var mb := event as InputEventMouseButton
if not mb.pressed:
return
# A click ON THE DOCK is the dock's, even where its containers let it through (#109).
if BuildDock != null and BuildDock.covers(mb.position):
return
var ops := _ensure_ops()
# THE WHEEL DRIVES THE ACTIVE TOOL (user decision 2026-10-02): handed to it as its own
# ] / [ key, so every tool's size / offset / steps follows the wheel with no per-tool
# code. Cursor out only — while looking (RMB held) the wheel is the camera's, and
# over a panel the GUI scrolls it before this ever sees it.
if ops.active != null:
_tool_mouse(mb)
return
if ops.active == null:
# CLICK TO SELECT, which is how every editor picks and how you asked for it.
# `B` still works; this is the gesture your hand is already making. Only while
# BUILDING: the builder context is additive, so it is live during play on this
# map too, and there a left click is the grapple.
if mb.button_index != MOUSE_BUTTON_LEFT or not GameMode.is_building():
return
_ensure_persistence()
# A SELECTED SHAPE'S FACE HANDLE under the click starts its drag (§17.7 Q3a); the
# release writes it. Shift-click still extends the selection.
if mb.pressed and not mb.shift_pressed and press_shape_handle(mb.position):
var hvp := get_viewport()
if hvp != null:
hvp.set_input_as_handled()
return
# SHIFT EXTENDS. Blender, TrenchBroom and every editor in between; the one
# gesture nobody has to be taught.
#
# Picked at the position the CLICK carries, not wherever the pointer has got
# to by now. They agree in the ordinary case and the event is the authority
# in every other one.
_pick_element(mb.shift_pressed, mb.position)
var pick_vp := get_viewport()
if pick_vp != null:
pick_vp.set_input_as_handled()
return
## A RUNNING TOOL OWNS THE MOUSE (#124; user 2026-10-18: "when doing a mouse move with g
## it seems i can still select things in the panels? that should be an override since i
## expect the mouse travel to not have other panels interactive"). Blender's modal
## operators take every event. While a tool is up in build mode with the cursor out,
## every mouse event is taken HERE, before the GUI: no panel hovers, scrolls or clicks
## under the travelling pointer, and the buttons and wheel go to the tool as they would
## over the 3D view. The tool reads the pointer itself each frame (_crosshair_ray), so
## consuming the motion costs it nothing. Looking (RMB held) captures the pointer and is
## not this path.
func _input(event: InputEvent) -> void:
if level == null or _ops == null or not is_instance_valid(_ops) or _ops.active == null:
return
if not (event is InputEventMouse) or not GameMode.is_building() or not GameMode.wants_pointer():
return
if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
_tool_mouse(event as InputEventMouseButton)
elif event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
# A GIZMO DRAG (a face handle, §17.7 Q3a) writes on RELEASE; one that moved nothing
# is let go.
var rop: Object = _ops.active
if rop != null and (rop.modal() as ModalControls).grabbing():
(rop.modal() as ModalControls).release_handle() # a held dot lets go (§17.9)
elif rop != null and rop.has_method("confirms_on_release") and bool(rop.confirms_on_release()):
if not _ops.confirm():
_ops.cancel()
elif event is InputEventMouseMotion:
var hop: Object = _ops.active
if hop != null and (hop.modal() as ModalControls).grabbing():
(hop.modal() as ModalControls).drag_handle(_crosshair_ray())
_ops.update_preview()
else:
_tool_drag(event as InputEventMouseMotion)
var vp := get_viewport()
if vp != null:
vp.set_input_as_handled()
## MOUSE DRAG OF THE FOCUSED VALUE (§17.7 Q2) for a tool that drags_value(): the change in
## the VIRTUAL pointer's x (mouse + wrap offset, so a wrap at the view's edge never jumps
## the value), Shift finer, Ctrl snapped (ModalControls.drag).
var _drag_x: Variant = null
var _drag_op: Object = null
func _tool_drag(mm: InputEventMouseMotion) -> void:
var op: Object = _ops.active
if op == null or not op.has_method("drags_value") or not bool(op.drags_value()):
_drag_x = null
return
var x: float = _pointer_pos(mm.position).x + _wrap_offset.x
if op != _drag_op or _drag_x == null:
_drag_op = op
_drag_x = x
return
var dx: float = x - float(_drag_x)
_drag_x = x
if (op.modal() as ModalControls).drag(dx, mm.shift_pressed, mm.ctrl_pressed or mm.meta_pressed):
_ops.update_preview()
## The panel shield (BuildDock.set_modal) is up exactly when _input owns the mouse: a tool
## is up AND the pointer is free. While looking (RMB held) the pointer is captured, the GUI
## still gets clicks at the screen centre, and a shield there would swallow the tool's LMB
## and the RMB release that ends the look — so it comes down then.
func _sync_modal() -> void:
if BuildDock == null:
return
var up: bool = _ops != null and is_instance_valid(_ops) and _ops.active != null
BuildDock.set_modal(up and GameMode.wants_pointer())
## A mouse button while a tool is up: the wheel is its ] / [ (every tool's size / offset /
## steps, user decision 2026-10-02), LMB confirms, RMB cancels.
func _tool_mouse(mb: InputEventMouseButton) -> void:
var ops := _ensure_ops()
if ops.active == null or not mb.pressed:
return
var more := Keymap.tapped(mb, "builder.tool_more")
if GameMode.wants_pointer() and (more or Keymap.tapped(mb, "builder.tool_less")):
var k := InputEventKey.new()
k.keycode = KEY_BRACKETRIGHT if more else KEY_BRACKETLEFT
k.physical_keycode = k.keycode
k.pressed = true
ops.handle_key(k)
elif mb.button_index == MOUSE_BUTTON_LEFT:
# A TOOL'S HANDLE under the press is grabbed instead of confirming (§17.9).
var hvp := get_viewport()
var hmc: ModalControls = ops.active.modal()
if hmc.grab_handle(mb.position, hvp.get_camera_3d() if hvp != null else null, _crosshair_ray(mb.position)):
return
ops.confirm()
elif mb.button_index == MOUSE_BUTTON_RIGHT:
ops.cancel()
print("[MapDCLevel] operator cancelled — nothing was written")
else:
return
_update_op_hud()
var vp := get_viewport()
if vp != null:
vp.set_input_as_handled()
## Undo or redo my most recent edit — of ANY kind, from ANY session.
##
## The order rule is in LevelPersistence: undo takes my highest-seq live record,
## redo my lowest-seq undone one, which are exact inverses over a run of undos
## without storing an undo stack anywhere. Both then re-fold, because the level is
## the journal and nothing else needs telling.
func _undo_redo(redo: bool) -> void:
var dot: String = _persist.redo_last() if redo else _persist.undo_last()
if dot == "":
print("[MapDCLevel] nothing to %s (undo %d / redo %d available)"
% ["redo" if redo else "undo", _persist.undo_depth(), _persist.redo_depth()])
return
_persist.refold()
print("[MapDCLevel] %s %s — %d undoable, %d redoable %s"
% ["redo" if redo else "undo", dot, _persist.undo_depth(), _persist.redo_depth(),
str(level.get_stats())])
## The immediate `_clip` that used to live here is GONE, replaced by KnifeOperator.
## Keeping both would have been two implementations of one operation — the thing that
## goes wrong every time it is allowed to happen. What the knife adds is the moment in
## which to aim: the old version had to infer the cut plane from wherever the camera
## happened to point, because pressing the key was the whole interaction.
## Tombstone the aimed brush. The other half of making a doorway.
##
## A TOMBSTONE, never an erase: GameState has no durable delete path, so an erased
## record returns on the next load. remove_brush has existed since the level pivot and
## has never had a binding — you could create geometry and never remove any.
func _delete_aimed() -> void:
var hit := _crosshair_hit()
if hit.is_empty():
print("[MapDCLevel] delete: nothing under the crosshair")
return
var brush := _brush_from_hit(hit)
if brush < 0:
print("[MapDCLevel] delete: that is not level geometry")
return
var bkey: String = level.get_brush_key(brush)
if not level.remove_brush(brush):
return
level.rebuild()
_refresh_brush_view()
_clear_face_selection() # the selection may have been on that brush
_persist.record("brush.remove", {"brush_key": bkey})
print("[MapDCLevel] deleted brush %d (%s) — Z to bring it back %s"
% [brush, bkey, str(level.get_stats())])
## CRASH PROBE. v868 carried a probe on the knife and NOT ONE of its lines appears in
## a crash log — so `C` never reaches the knife, and the last thing standing in every
## log is a pick, cut off mid-print. Everything the pick touches has been cleared
## offline on the real level (cut_preview and split_brush over all 1183 brushes, the
## operator over 399, BrushView over 1300 aims plus out-of-range ids, under both
## engines and a live Vulkan device), so the fault is in something only the running
## game reaches. This makes the pick say where it got to; stdout is line-buffered
## under a pty, so the last line printed is the step that died.
func _probe(what: String) -> void:
print("[pick.probe] %s" % what)
## Raycast from the crosshair into the level. Shared by carving and face editing
## because the exclusion below is not optional and must not diverge between them.
##
## EXCLUDE THE PLAYERS. The camera sits inside its own player capsule, so an
## unexcluded ray hits that first and returns a point floating in mid-air. A
## mid-air point is inside NO brush's AABB — a room's walls are thin slabs at its
## edges, not a volume filling it — so apply_carve recorded a carve whose
## `touched` list was empty and the level was left completely unchanged. That is
## exactly what the first build did: 14 carves, pockets 0, triangles never moved
## off 13175.
## THE AIM ITSELF, without asking what it hits. An operator that FOLLOWS your aim
## needs the ray even where there is nothing to hit — you drag a face out into empty
## space, and a hit-only view of the aim would go blank exactly then.
## The ray every editor operation acts along — pick, delete, and every operator.
##
## IT FOLLOWS THE CURSOR WHEN THERE IS ONE. This used to be the camera's forward
## axis unconditionally, i.e. the screen-centre crosshair, which is right when the
## mouse is captured and useless the moment it is not: build mode grew a visible
## cursor, and pointing at a brush still selected whatever happened to be in the
## middle of the screen. "The mouse cannot select brushes or elements" is that,
## and it is one ray, not a selection-model problem.
##
## Named _crosshair_ray still because the crosshair is exactly what it is whenever
## the mouse is captured — during RMB-hold look, and in play.
## `at` is a viewport position to aim from, for a caller that HAS one. A click
## carries where it happened, and that is not the same thing as where the pointer
## is by the time the handler runs. Everything that polls an aim during a drag
## wants the live pointer and passes nothing, which is the default.
func _crosshair_ray(at: Variant = null) -> Dictionary:
var vp := get_viewport()
if vp == null:
return {}
var cam := vp.get_camera_3d()
if cam == null:
return {}
if GameMode.wants_pointer():
# + the WRAP OFFSET: while a pointer-following tool is up, the mouse is warped
# across the window at its edge and this carries the drag on (#109).
var m: Vector2 = (at as Vector2) if at != null else _pointer_pos(vp.get_mouse_position()) + _wrap_offset
return {"from": cam.project_ray_origin(m), "dir": cam.project_ray_normal(m)}
# Mouse captured: the pointer says nothing, the camera IS the aim, and a
# position handed in by a caller means nothing here.
return {"from": cam.global_position, "dir": -cam.global_transform.basis.z}
## ---- POINTER WRAP (#109, 2026-10-12: "when pressing g and trying to move something a
## very large distance the mouse has to wrap to continue moving beyond the window bounds")
##
## Blender's continuous grab. While a tool whose value FOLLOWS THE POINTER is up
## (LevelOperator.follows_aim: move, element move, extrude) and the pointer is free
## (build mode, not RMB-look), the mouse reaching within WRAP_MARGIN of an edge is warped
## to the opposite side, and the jump is added to _wrap_offset — the VIRTUAL pointer
## (mouse + offset) carries on as if the window were endless. _crosshair_ray reads it.
## The offset belongs to one tool: it resets the moment the active tool changes.
const WRAP_MARGIN := 4.0
var _wrap_offset := Vector2.ZERO
var _wrap_op: Object = null
## Where the warp goes — swapped by gate_pointer_wrap (headless has no OS cursor to move).
var pointer_warp: Callable = Callable(self, "_warp_pointer")
func _warp_pointer(p: Vector2) -> void:
var vp := get_viewport()
if vp != null:
vp.warp_mouse(p)
## Where a pointer at `m` in a viewport of `size` lands after the wrap (itself when no
## wrap is due). The far side is placed one pixel INSIDE the margin, so it does not
## wrap straight back.
static func wrap_target(m: Vector2, size: Vector2, margin: float) -> Vector2:
var out := m
for i in 2:
var span: float = size[i] - 2.0 * (margin + 1.0)
if span <= 0.0:
continue
if m[i] >= size[i] - margin:
out[i] = m[i] - span
elif m[i] <= margin:
out[i] = m[i] + span
return out
## The mouse position the wrap and the ray should read. After a warp the viewport may
## still report the OLD position until the next motion event; read raw, that would count
## the jump twice for a frame (the brush flicking a window-width away). So the target
## stands in until the viewport reports a position near it.
var _wrap_pending: Variant = null
func _pointer_pos(raw: Vector2) -> Vector2:
if _wrap_pending == null:
return raw
if raw.distance_to(_wrap_pending as Vector2) <= WRAP_MARGIN * 4.0:
_wrap_pending = null
return raw
return _wrap_pending as Vector2
func _update_pointer_wrap_at(raw: Vector2) -> void:
var op: Object = _ops.active if _ops != null and is_instance_valid(_ops) else null
if op != _wrap_op:
_wrap_op = op
_wrap_offset = Vector2.ZERO
_wrap_pending = null
var m := _pointer_pos(raw)
# A tool whose value DRAGS with the mouse (§17.7 Q2) wraps too: Blender's continuous grab.
var wraps: bool = op != null and ((op.has_method("follows_aim") and bool(op.follows_aim()))
or (op.has_method("drags_value") and bool(op.drags_value())))
if not wraps or not GameMode.wants_pointer():
return
var vp := get_viewport()
if vp == null:
return
# Wrap at the 3D VIEW's edges, not the window's (#124): the window's edges are the
# editor's areas now, and a pointer wrapped there sat over a panel.
var rect: Rect2 = BuildDock.view_rect() if BuildDock != null else vp.get_visible_rect()
var w := wrap_target(m - rect.position, rect.size, WRAP_MARGIN) + rect.position
if w == m:
return
_wrap_offset += m - w
_wrap_pending = w
pointer_warp.call(w)
## Is the clearance view on? Held here rather than read back off BrushView because
## it governs PICKING as well as drawing, and _crosshair_hit runs before the view
## is necessarily built.
var _show_clearance: bool = false
## LIVE CUTTERS are drawn and pickable whenever any exist (docs/cutting-and-operations.md
## §9a): they have no collider, so only dc_pick sees them. The toggle is step 2.
var _show_cutters: bool = true
## `reach` < 0 = _aim_reach() (the camera's far plane in build mode); carving passes
## PLAY_REACH so its limit is the same in every mode.
func _crosshair_hit(at: Variant = null, reach: float = -1.0) -> Dictionary:
var ray := _crosshair_ray(at)
if ray.is_empty():
return {}
# Act where the player is LOOKING, not where they stand — an edit at the
# camera would open a hole under your feet and read as a bug.
var from: Vector3 = ray["from"]
var to: Vector3 = from + (ray["dir"] as Vector3) * (reach if reach > 0.0 else _aim_reach())
var q := PhysicsRayQueryParameters3D.create(from, to)
q.collide_with_areas = false
var excl: Array[RID] = []
var players := get_tree().root.get_node_or_null("Game/Players")
if players != null:
for p in players.get_children():
if p is CollisionObject3D:
excl.append((p as CollisionObject3D).get_rid())
q.exclude = excl
var hit: Dictionary = get_world_3d().direct_space_state.intersect_ray(q)
# Cutters only on a DCLevel build that has them, and only when one exists — the
# dc_pick ray below is not free, and most levels have none.
var cutters_on: bool = _show_cutters and level != null and level.has_method("get_cutter_brush_ids") \
and not (level.get_cutter_brush_ids() as PackedInt32Array).is_empty()
if not _show_clearance and not cutters_on:
return hit
# CLEARANCE HAS NO COLLIDER — that is what it is — so the physics ray above
# cannot see one however close it is, and the clearance view would show
# volumes you could look straight at and never select. dc_pick answers from
# the plane sets with no physics involved, and is the only path that can.
#
# NEARER WINS. A clearance volume encloses the shape it serves, so it is
# almost always the closer of the two; taking it unconditionally would make
# the walls unselectable for as long as the view is on, and taking the
# physics hit unconditionally would make this do nothing.
var to_end: Vector3 = to
var pick: Dictionary = {}
if level != null and level.has_method("raycast"):
pick = level.raycast(from, to_end, _show_clearance, true) if cutters_on \
else level.raycast(from, to_end, true)
if pick.is_empty():
return hit
var ids: PackedInt32Array = level.get_clearance_brush_ids() if _show_clearance else PackedInt32Array()
if cutters_on:
ids.append_array(level.get_cutter_brush_ids())
if not ids.has(int(pick.get("brush", -1))):
return hit # dc_pick found ordinary geometry; physics owns that
if not hit.is_empty() and \
from.distance_to(hit.get("position", to_end) as Vector3) <= float(pick.get("distance", 0.0)):
return hit
# Shaped like a physics result so every caller reads it the same way, plus
# `brush` — _brush_from_hit resolves a shape index, and a clearance volume has
# no shape to index.
return {
"position": pick.get("position", Vector3.ZERO),
"normal": pick.get("normal", Vector3.UP),
"brush": int(pick.get("brush", -1)),
"collider": null, "shape": -1,
}
# ---- (B) face-level shape editing ------------------------------------------
#
# The architecture calls face editing "the tractable subset" and it is right
# about why: a brush vertex is where 3+ planes meet, so it cannot move on its
# own, and solving for that is the TrenchBroom problem. A FACE is one plane, and
# moving it along its own normal is exactly one number.
## THE GRID IS IN QUAKE UNITS, NOT METRES, and that is the whole point.
##
## The .MAP is authored on an integer Quake lattice and imported at WORLD_SCALE,
## so the only steps that keep a face on the grid the map was BUILT on are
## integer multiples of a unit. The first version of this nudged by 0.125 m,
## which is 4.4643 Quake units — every keypress walked the face off that lattice,
## and ten of them left it 44.6 units off, permanently and invisibly. Faces still
## looked fine; they were simply no longer where a mapper could reason about them.
##
## 8 units (0.224 m) is the default because it is already this codebase's
## quantum: dc_level.h picks the same value for pocket_voxel_size so a carved
## pocket lands on the same lattice as everything else.
##
## Powers of two, like TrenchBroom's grid (exp2 sizes of the map unit) — and its range,
## 0.25 .. 256 (editor redesign E2; TB is 0.125 .. 256).
const GRID_UNITS: Array[float] = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0]
## REMEMBERED per player (BuildDock prefs, user://editor_layout.json — never replicated):
## the setters save, _init restores, so a new map opens on the last grid and snap state.
var _grid_idx: int = 5:
set(v):
_grid_idx = v
_save_editor_pref("grid", v)
## SNAPPING (editor redesign E2, docs/editor-redesign.md §4). Per player, not durable.
## OFF = steps of ONE unit, TrenchBroom's rule: brushes stay on whole units, which the
## csg4 clip and the journal's plane equality are both happiest with. While a TOOL is
## up, Ctrl inverts this and Shift divides the step by PRECISION (keymap.md rule 1c —
## outside a tool those keys fly, so they never touch snapping there).
var snap_enabled: bool = true:
set(v):
snap_enabled = v
_save_editor_pref("snap", v)
const PRECISION := 8.0
## CSG: tombstone the operands afterwards, or keep them (Alt+H toggles). Per player,
## remembered like the grid. See _csg for why discarding is the default.
var csg_discard: bool = true:
set(v):
csg_discard = v
_save_editor_pref("csg_discard", v)
var _prefs_loaded := false
func _init() -> void:
var dock := _dock_node()
if dock != null:
_grid_idx = clampi(int(dock.pref("grid", 5)), 0, GRID_UNITS.size() - 1)
snap_enabled = bool(dock.pref("snap", true))
csg_discard = bool(dock.pref("csg_discard", true))
var pm := String(dock.pref("pivot_mode", "bbox"))
pivot_mode = pm if PIVOT_MODES.has(pm) else "bbox"
_prefs_loaded = true
## Before the map is in the tree get_node_or_null cannot reach /root, so go through the
## main loop's root — the same node, reachable from _init.
func _dock_node() -> Node:
var ml := Engine.get_main_loop() as SceneTree
return ml.root.get_node_or_null("BuildDock") if ml != null else null
func _save_editor_pref(key: String, v: Variant) -> void:
if not _prefs_loaded:
return # restoring, not choosing
var dock := _dock_node()
if dock != null:
dock.set_pref(key, v)
func _tool_up() -> bool:
return Keymap.is_context_active("tool")
## The toggle as it applies RIGHT NOW: Ctrl held inside a tool inverts it.
func snap_active() -> bool:
var inv: bool = _tool_up() and Input.is_key_pressed(KEY_CTRL)
return snap_enabled != inv
## SNAP TARGETS (E2b): placements (box corner, knife point) snap to the aimed face's
## VERTICES / EDGES when one is within one grid step — Blender's Vertex / Edge targets,
## for "put this box's corner on that brush's corner". On top of the lattice, never
## instead of it; off whenever snapping is (so Ctrl inside a tool frees them too).
## Remembered per player.
func snap_target_enabled(kind: String) -> bool:
var dock := _dock_node()
return dock != null and bool(dock.pref("snap_" + kind, false))
func set_snap_target(kind: String, on: bool) -> void:
var dock := _dock_node()
if dock != null:
dock.set_pref("snap_" + kind, on)
## The vertex / edge point a placement at `at` (on brush `brush`) should snap to, or
## null to fall through to the lattice. Vertex beats edge: a corner is the more
## specific intent.
func snap_target_point(at: Vector3, brush: int) -> Variant:
if brush < 0 or level == null or not snap_active():
return null
var want_v := snap_target_enabled("vertex")
var want_e := snap_target_enabled("edge")
if not (want_v or want_e):
return null
var face: int = level.pick_face(brush, at)
if face < 0:
return null
var w: PackedVector3Array = level.get_face_winding(brush, face)
if w.size() < 3:
return null
var r: float = maxf(base_grid_units() * WORLD_SCALE, 0.05)
if want_v:
var best_v: Variant = null
var bd := r
for p: Vector3 in w:
var dd := p.distance_to(at)
if dd <= bd:
bd = dd
best_v = p
if best_v != null:
return best_v
if want_e:
var best_e: Variant = null
var be := r
for i in w.size():
var q: Vector3 = Geometry3D.get_closest_point_to_segment(at, w[i], w[(i + 1) % w.size()])
var de := q.distance_to(at)
if de <= be:
be = de
best_e = q
return best_e
return null
## The grid size chosen in the dropdown / with - =, ignoring snap and modifiers.
func base_grid_units() -> float:
return GRID_UNITS[_grid_idx]
## How a length reads in the player's chosen unit (Settings "editor_units": Quake units
## or metres) — every length the editor shows goes through here.
func fmt_units(u: float) -> String:
if int(Settings.get_value("editor_units")) == 1:
var m := "%.3f" % (u * WORLD_SCALE)
return "%s m" % m
# Whole units read "8u", not "8.0u"; fractions keep up to 3 places ("0.125u").
if is_equal_approx(u, roundf(u)):
return "%du" % int(roundf(u))
return "%su" % String.num(u, 3)
## SELECT MODE — what `B` picks, and therefore what `G` moves.
##
## Picking used to resolve vertex/edge/face by PROXIMITY: aim near a corner and you
## got the corner, aim in the middle and you got the face. One tool for three things,
## which was the right first move — but it means you cannot ASK for a face while
## standing near its corner, and there is no way to tell the editor what you meant.
## Blender solves this with modes on 1/2/3, and the number row is free here for the
## same reason it is there: build mode is exclusive, so there are no weapons to
## collide with. `4` adds the whole brush, which is Blender's Object Mode.
const SEL_MODES: Array[String] = ["vertex", "edge", "face", "brush"]
## Face, not vertex: a face is the tractable case this representation is built around
## (one plane, one number) and it is what most edits want.
var _sel_mode: String = "face"
## THE SELECTION IS A SET, and one member of it is ACTIVE.
##
## `_sel_brush` was one int, which is issue #23 item 4 and the thing groups, layers
## and every CSG operation were waiting on: "subtract THIS from THAT" cannot even be
## expressed with one selected brush. It now means THE ACTIVE ONE — the last you
## clicked — so every existing caller still reads the brush the user is working on,
## and `_sel_brushes` carries the rest.
##
## Blender's model, deliberately, and NOT TrenchBroom's: theirs has no active concept
## at all — csgSubtract takes the selected brushes as cutters and auto-selects
## everything TOUCHING them as the target (Map_Geometry.cpp:685-700). That is fewer
## clicks and alarming on a 1183-brush level, where a cutter grazing a floor and three
## walls would carve all four without being asked.
var _sel_brushes: Array[int] = []
var _sel_brush: int = -1
# VERTEX, EDGE AND FACE ARE ONE OPERATION, NOT THREE.
#
# An element of dimension k is the meet of (3-k) planes: a face is one plane, an
# edge two, a vertex three. So dragging any of them is the same act — move the
# incident planes so they pass through the target point, keeping their normals:
#
# d_i = n_i . P for each incident plane
#
# Exactly determined, never a solve. And the face case collapses to the nudge
# this file already had: d = n.(P + n*step) = d + step.
#
# This is NOT TrenchBroom's vertex drag. Theirs lets the shape GAIN faces, which
# needs a polyhedron rebuild, plane re-derivation and a heuristic matcher to work
# out which face is which afterwards — the topological naming problem. Ours moves
# the incident faces bodily: drag a cube corner and you get a bigger box, not a
# bevelled one. That is inherent, not a shortcut. A brush's faces are planar by
# definition, so moving one vertex while pinning its neighbours would need
# non-planar faces, which is not a brush at all. Verified exact (err 0.00000) for
# vertex and edge on a unit cube, with the opposite corner untouched.
#
# Selection is held as DOTS, never indices: remove_face renumbers, and a dot needs
# no agreed order to be unique.
var _sel_keys: PackedStringArray = PackedStringArray()
## FACES ACROSS BRUSHES (2026-10-05): face mode, Shift-click adds a face of ANY brush.
## {b: brush key, p: plane key} — durable names, like _sel_keys, so a fold between
## clicks cannot leave it pointing at the wrong face. The active face is still
## _sel_brush/_sel_keys (nudge, extrude act on it); this set is what Alt+J / Alt+E
## build from. Empty outside face mode.
var _sel_faces: Array[Dictionary] = []
## THE EDGE SET (#120 L3 §14.4): edge mode, Shift-click adds an edge of ANY brush, as
## _sel_faces does for faces. {b: brush key, p: [plane key, plane key], sorted} — the edge
## IS where those two planes meet on that brush, so a fold between clicks cannot leave it
## pointing elsewhere, and no picked point is kept. The ACTIVE edge is still
## _sel_edge_a/_b. Cables from edges (§14.5) read it. Empty outside edge mode.
var _sel_edges: Array[Dictionary] = []
## The edge a..b of `brush` as a durable key: the two planes through both ends. {} unless
## exactly two.
func _edge_key(brush: int, a: Vector3, b: Vector3) -> Dictionary:
var d: Dictionary = level.get_brush(brush)
var pl: PackedFloat32Array = d.get("planes", PackedFloat32Array())
var keys: PackedStringArray = d.get("plane_keys", PackedStringArray())
var found: Array = []
for i in mini(pl.size() / 4, keys.size()):
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
var l := n.length()
if l < 1.0e-9:
continue
var dd := pl[i * 4 + 3] / l
n /= l
if absf(n.dot(a) - dd) < FaceKit.ON_PLANE and absf(n.dot(b) - dd) < FaceKit.ON_PLANE:
found.append(String(keys[i]))
if found.size() != 2:
return {}
found.sort()
return {"b": String(level.get_brush_key(brush)), "p": found}
## One edge key resolved on the level now: {b, p, a, e (the two ends, in (x, y, z) order),
## normals: [n1, n2] (the planes', in p's order)}, or {} when the brush is gone or its two
## planes no longer meet in a segment of it. `live` = the live brush ids as keys: a removed
## brush is a TOMBSTONE, which brush_by_key and get_brush still return.
func _resolve_edge(ek: Dictionary, live: Dictionary) -> Dictionary:
var b: int = level.brush_by_key(String(ek.get("b", "")))
if b <= 0 or not live.has(b):
return {}
var d: Dictionary = level.get_brush(b)
var pl: PackedFloat32Array = d.get("planes", PackedFloat32Array())
var keys: PackedStringArray = d.get("plane_keys", PackedStringArray())
var p: Array = ek.get("p", [])
if p.size() != 2:
return {}
var ns: Array = []
var ds: Array = []
for k: String in p:
var i := keys.find(k)
if i < 0 or i * 4 + 3 >= pl.size():
return {}
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
var l := n.length()
if l < 1.0e-9:
return {}
ns.append(n / l)
ds.append(pl[i * 4 + 3] / l)
var on: Array = []
for v: Vector3 in FaceKit.corners(pl):
if absf((ns[0] as Vector3).dot(v) - float(ds[0])) < FaceKit.ON_PLANE \
and absf((ns[1] as Vector3).dot(v) - float(ds[1])) < FaceKit.ON_PLANE:
on.append(v)
if on.size() != 2:
return {}
if FaceKit._before(on[1], on[0]):
on.reverse()
return {"b": ek["b"], "p": p, "a": on[0], "e": on[1], "normals": ns}
## The edge set, resolved (see _resolve_edge); edges that no longer resolve are left out.
func selected_edges() -> Array:
var out: Array = []
if _sel_edges.is_empty():
return out
var live := {}
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
live[id] = true
for ek: Dictionary in _sel_edges:
var r := _resolve_edge(ek, live)
if not r.is_empty():
out.append(r)
return out
## The edge set as line pairs, for the highlight.
func edge_set_lines() -> PackedVector3Array:
var out := PackedVector3Array()
for r: Dictionary in selected_edges():
out.append(r["a"])
out.append(r["e"])
return out
## The `face_sel` keymap context follows "is anything selected" (_process).
var _face_sel_on := false
## Home / End pressed again on the SAME selection step to the next fit / edge.
var _last_uv_key := ""
var _last_uv_sig := ""
var _sel_point := Vector3.ZERO
var _sel_kind: String = ""
## The selected EDGE's endpoints, captured at pick time. _pick_element already
## computes them while testing each bounding segment; recovering them afterwards
## would mean intersecting the two incident planes and re-clipping against the brush
## to find where the edge actually stops.
var _sel_edge_a := Vector3.ZERO
var _sel_edge_b := Vector3.ZERO
var _hl: MeshInstance3D = null
## Snap a world-space plane offset to the MAP's lattice, not the world's.
##
## MEASURED, because I had assumed this was badly broken and it is not: the importer
## snaps map_center to Quake's 8-unit grid (for DC voxel alignment), so with
## world_offset zero the map's 8-unit lattice lands exactly on multiples of 8 *
## WORLD_SCALE in world space. Grids of 1, 2, 4 and 8 units were therefore ALREADY
## correct with naive world-origin snapping.
##
## What is genuinely wrong is the coarse end. START.MAP's map_center is
## (736, -88, -1704), which is a multiple of 8 but NOT of 16, 32 or 64 — so at those
## grid sizes a world-origin snap lands 8 or more units off the lattice the map was
## authored on, on whichever axes do not divide. This converts the offset back into
## Quake units, snaps there, and converts back, which is right for every grid size.
static func snap_to_map_grid(d: float, n: Vector3, units: float,
map_center: Vector3, aligned_offset: Vector3) -> float:
var origin: float = n.dot(map_center)
var quake: float = (d - n.dot(aligned_offset)) / WORLD_SCALE + origin
var snapped: float = roundf(quake / float(units)) * float(units)
return (snapped - origin) * WORLD_SCALE + n.dot(aligned_offset)
## One grid step in metres. Integer Quake units scaled into world space, so a
## nudged face stays on the lattice the .MAP was authored on.
func _grid_step() -> float:
return grid_units() * WORLD_SCALE # the EFFECTIVE step (E2): snap + modifiers
## The collider carries which brush it came from; the shape index is the join.
func _brush_from_hit(hit: Dictionary) -> int:
# An explicit `brush` beats the shape index: a clearance volume has no
# collision shape to index, so _crosshair_hit names the brush directly when
# the clearance view answers the ray.
if hit.has("brush"):
return int(hit["brush"])
if hit.is_empty() or not hit.has("shape") or not level.has_method("get_brush_id_from_shape"):
return -1
# ONLY THE LEVEL'S OWN BODY. A shape index means nothing on another body: a scene
# box's shape 0 named the level's brush 0 — an unrelated brush that a click
# selected and Delete removed (gate_pick_level_only). DCLevel attaches its body to
# itself (dc_level.cpp body_attach_object_instance_id), so its hits report it.
if hit.get("collider") != level:
return -1
# EACH COLLIDER HAS ITS OWN BODY now (dc-terrain, docs/incremental-fold.md step 2), so
# the hit's body names the brush and its shape index is always 0 there.
if hit.has("rid") and level.has_method("get_brush_id_from_body"):
return level.get_brush_id_from_body(hit["rid"])
return level.get_brush_id_from_shape(int(hit["shape"]))
## THE OUTLINER'S TWO ENTRY POINTS, and they are the map's API rather than the
## panel reaching into private state — a second writer of `_sel_brushes` or
## `_show_clearance` is how the two would drift, which is the mistake the
## mode/fly pair already made once.
## Show or hide the clearance wireframe, and make those volumes pickable with it.
## Called by Shift+V and by the outliner's Clearance row, so they cannot disagree.
## The clearance OVERLAY's own state (keymap step 6.5) — `_show_clearance` is the
## effective one (and the master switch).
func set_clearance_shown(shown: bool) -> void:
set_overlay("clearance", shown)
# ---- OVERLAYS (keymap step 6.5, docs/keymap-blender-279.md §6a.5) ------------------
#
# Blender 2.8's model: a MASTER switch (Shift+Alt+Z) over one switch per overlay. Master
# off hides them all and leaves each one's own state alone. ONE state (BuildDock prefs —
# per player, CI-isolated), ONE apply pushing the EFFECTIVE state to every consumer.
# The aimed outline, its handles and the selection outlines are NOT overlays: they are
# the tool's answer to "what will a click pick".
## name -> [action, label, default]. The grid keeps its pre-existing pref key.
const OVERLAYS: Dictionary = {
"outlines": ["builder.brush_view", "Brush outlines", true],
"clearance": ["builder.clearance_view", "Clearance volumes", false],
"links": ["builder.entity_links", "Entity links", true],
"grid": ["builder.grid_view", "Grid", true],
"cutters": ["builder.cutters_view", "Live cutters", true],
"curves": ["builder.curves_view", "Curves", true],
}
var _overlay_mem: Dictionary = {} # only when there is no BuildDock (bare harnesses)
func _overlay_pref_key(name: String) -> String:
return "grid_view" if name == "grid" else "overlay_" + name
func _pref_get(key: String, default: bool) -> bool:
var dock := get_node_or_null("/root/BuildDock")
if dock != null:
return bool(dock.pref(key, default))
return bool(_overlay_mem.get(key, default))
func _pref_set(key: String, value: bool) -> void:
var dock := get_node_or_null("/root/BuildDock")
if dock != null:
dock.set_pref(key, value)
else:
_overlay_mem[key] = value
## The overlay's OWN switch (what its row's check mark shows).
func overlay_on(name: String) -> bool:
return _pref_get(_overlay_pref_key(name), bool((OVERLAYS[name] as Array)[2]))
## The master switch (Shift+Alt+Z).
func overlays_all_on() -> bool:
return _pref_get("overlays_all", true)
## What is actually drawn: the master AND the overlay's own switch.
func overlay_shown(name: String) -> bool:
return overlays_all_on() and overlay_on(name)
func set_overlay(name: String, on: bool) -> void:
_pref_set(_overlay_pref_key(name), on)
_apply_overlays()
func set_overlays_all(on: bool) -> void:
_pref_set("overlays_all", on)
_apply_overlays()
## A key / menu row: flip one overlay and SAY what happened, with a count — "on and
## nothing to draw" must read differently from a key that does nothing.
func toggle_overlay(name: String) -> void:
set_overlay(name, not overlay_on(name))
var n := -1
match name:
"outlines":
n = _ensure_brush_view().segment_count() if level != null else 0
"clearance":
n = (level.get_clearance_brush_ids() as PackedInt32Array).size() \
if level != null and level.has_method("get_clearance_brush_ids") else 0
"links":
n = _ensure_entity_links().link_count()
"cutters":
n = (level.get_cutter_brush_ids() as PackedInt32Array).size() \
if level != null and level.has_method("get_cutter_brush_ids") else 0
"curves":
n = curve_states().size()
print("[MapDCLevel] %s %s%s%s" % [String((OVERLAYS[name] as Array)[1]).to_lower(),
"on" if overlay_on(name) else "off", " (%d)" % n if n >= 0 else "",
"" if overlays_all_on() else " — all overlays are off (%s)" % Keymap.describe("builder.overlays_all")])
## Push the effective states. Touches only nodes that exist; _ensure_brush_view and
## _ensure_entity_links take theirs when they are made.
func _apply_overlays() -> void:
_show_clearance = overlay_shown("clearance")
_show_cutters = overlay_shown("cutters")
if _brush_view != null and is_instance_valid(_brush_view):
_push_overlays(_brush_view)
if _entity_links != null and is_instance_valid(_entity_links):
_entity_links.visible = _links_visible()
if _curve_view != null and is_instance_valid(_curve_view):
_curve_view.visible = _curves_visible()
## Curves (wire + points) are an editing aid: build mode, with their overlay (user
## 2026-10-18: "the cable points ... dont go away when you press f4").
func _curves_visible() -> bool:
return GameMode.is_building() and overlay_shown("curves")
func _push_overlays(bv: BrushView) -> void:
bv.show_outlines = overlay_shown("outlines")
bv.show_clearance = _show_clearance
bv.show_cutters = _show_cutters
## Entity links show in build mode, with their overlay, unless the Outliner's eye hid
## the node (outliner_model.gd sets that meta and calls _apply_overlays).
func _links_visible() -> bool:
return GameMode.is_building() and overlay_shown("links") \
and not bool(_entity_links.get_meta("outliner_hidden", false))
## Select a set of brushes from outside the viewport. The outliner selects by
## ROW — a collection, a derived group — and everything downstream is unchanged:
## `G` moves what is selected using the operator that already exists, which is
## #28 §1's whole argument for why the panel is not a new way to move things.
# ---- PARAMETRIC SHAPES in the editor (docs/parametric-shapes-design.md §4, §6) ------
#
# A shape is ONE thing: clicking any of its pieces selects all of them, in every pick
# mode — the pieces are READ-ONLY (§7, user Q1), so a face pick must not reach one.
# Move / rotate / scale on a selected shape write shape.xform / shape.size (the fold
# regenerates the pieces), never per-piece brush records, which the next generation
# would orphan. Make Real (§6) hands the pieces over as plain brushes.
#
# _sel_shape is HELD, not derived: every edit regenerates the pieces under NEW names
# (shape_fold.gd <ver>), so the ids in _sel_brushes die on each fold — _reselect_shape
# re-takes them from the shape's group after it.
var _sel_shape := ""
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
## The live shape a brush is a piece of, or "".
func shape_of_brush(id: int) -> String:
if id < 0 or level == null:
return ""
_ensure_persistence()
var g := String(level.get_brush_group(id))
return g if g != "" and (_persist.live_shapes() as Dictionary).has(g) else ""
func selected_shape() -> String:
return _sel_shape
func select_shape(dot: String) -> void:
select_brushes(level.get_group_brush_ids(dot))
_sel_shape = dot
print("[MapDCLevel] selected shape %s (%d pieces)" % [dot, _sel_brushes.size()])
func clear_selection() -> void:
_clear_face_selection()
## After a fold: the selected shape's pieces have new ids (or it is gone).
func _reselect_shape() -> void:
if _sel_asset != "":
# A placed asset (#120 L4): its pieces are renamed by a move, or it is gone.
var pd := _sel_asset
var p: Dictionary = (_persist.placements() as Dictionary).get(pd, {})
if p.is_empty() or bool(p["removed"]):
_clear_face_selection()
return
var aids := asset_brush_ids(pd)
if aids != PackedInt32Array(_sel_brushes):
select_brushes(aids)
_sel_asset = pd
return
if _sel_shape == "":
return
var dot := _sel_shape
if not (_persist.live_shapes() as Dictionary).has(dot):
_clear_face_selection()
return
var ids: PackedInt32Array = level.get_group_brush_ids(dot)
if ids == PackedInt32Array(_sel_brushes):
return
select_brushes(ids)
_sel_shape = dot
## The live shapes among `ids`, the ids that are plain brushes, and the PLACED ASSETS
## (#120 L4) whose pieces are among them: [{dot: true}, [ids], {placement dot: true}]. A
## placement's pieces are never plain: every edit goes to its record (asset.*).
func _split_shapes(ids: Array) -> Array:
var shapes := {}
var rest: Array = []
var assets := {}
for b: Variant in ids:
var pa := placement_of_brush(int(b))
if pa != "":
assets[pa] = true
continue
var d := shape_of_brush(int(b))
if d != "":
shapes[d] = true
else:
rest.append(int(b))
return [shapes, rest, assets]
# ---- PLACED ASSETS in the editor (#120 L4 §15.3) ----------------------------------
#
# A placement is ONE thing, like a shape: clicking any of its pieces (or its objects)
# selects it; G / R write asset.xform, X asset.remove, Shift+D / Alt+D a new asset.place;
# its parts are read-only (Make Real hands them over). _sel_asset is HELD like _sel_shape:
# a move renames its pieces, so _reselect_shape re-takes them after the fold.
var _sel_asset := ""
## The placement a brush is a piece of, or "".
func placement_of_brush(id: int) -> String:
if id < 0 or level == null:
return ""
_ensure_persistence()
var g := String(level.get_brush_group(id))
return String(_persist.instance_of(g)) if g != "" else ""
## A placement's live brush ids.
func asset_brush_ids(pdot: String) -> PackedInt32Array:
var out := PackedInt32Array()
if level == null or pdot == "":
return out
_ensure_persistence()
for b: int in (level.get_live_brush_ids() as PackedInt32Array):
var g := String(level.get_brush_group(b))
if g != "" and String(_persist.instance_of(g)) == pdot:
out.append(b)
return out
func selected_asset() -> String:
return _sel_asset
func select_asset(pdot: String) -> void:
select_brushes(asset_brush_ids(pdot))
_sel_asset = pdot
print("[MapDCLevel] selected placed asset %s (%d pieces)" % [pdot, _sel_brushes.size()])
## Write a placement's new frame (asset.xform). True if recorded.
func _record_asset_frame(pdot: String, frame: Transform3D) -> bool:
return _persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)}) != ""
func _asset_frame(pdot: String) -> Transform3D:
return (_persist.placements() as Dictionary).get(pdot, {}).get("frame", Transform3D.IDENTITY)
## Shift+D / Alt+D on a placement: a new asset.place of the same asset at the same frame,
## linked into the same collections (every placement is already linked data). Returns its dot.
func duplicate_asset(pdot: String) -> String:
var p: Dictionary = (_persist.placements() as Dictionary).get(pdot, {})
if p.is_empty():
return ""
var d: String = _persist.record("asset.place", {"asset": p["asset"], "name": p["name"],
"frame": SceneObjects.xform_to(p["frame"])})
if d != "":
var t: Dictionary = _persist.collection_tree()
for c: String in CollectionFold.collections_of(t, "a:" + pdot):
_persist.record("col.link", {"target": c, "members": ["a:" + d]})
return d
## MAKE REAL on a placement (§15.3): its records — exactly what the fold expands it to
## (AssetFold.payloads, shared) — journalled as ordinary ones under FRESH dots, shapes still
## parametric and cuts still live, in a NEW collection named after the asset (its
## sub-collections beneath it, under the placement's first collection if it is in one); then
## asset.remove. ONE gesture: Ctrl+Z brings the placement back. Returns how many records.
func make_real_asset(pdot: String) -> int:
_ensure_persistence()
var p: Dictionary = (_persist.placements() as Dictionary).get(pdot, {})
var docs: Dictionary = _persist.get("_asset_docs")
if p.is_empty() or bool(p["removed"]) or not docs.has(String(p["asset"])):
print("[MapDCLevel] make real: %s is not a placement whose asset is here" % pdot)
return 0
var doc: Dictionary = docs[String(p["asset"])]
var names := LevelPersistence.AssetFold.local_names(doc)
var fresh := {}
var locals: Array = names.keys()
locals.sort_custom(func(a: String, b: String) -> bool: return int(names[a]) < int(names[b]))
_persist.begin_gesture("Make Real")
for l: String in locals:
fresh[l] = _persist.reserve_dot()
var dot_of := func(l: String) -> String: return String(fresh.get(l, ""))
var n := 0
for e: Dictionary in LevelPersistence.AssetFold.payloads(p, doc, dot_of):
var pl: Dictionary = (e["payload"] as Dictionary).duplicate(true)
var kind := String(pl["kind"])
pl.erase("kind")
var at: String = fresh[String(e["local"])] if String(e["suffix"]) == "" else _persist.reserve_dot()
if _persist.record_at(at, kind, pl):
n += 1
# Organisation: one collection named after the asset holds the top level; the blob's
# sub-collections are made beneath it with their members remapped.
var t: Dictionary = _persist.collection_tree()
var home := CollectionFold.collections_of(t, "a:" + pdot)
var top: String = _persist.record("col.create", {"name": String(p["name"]),
"parent": String(home[0]) if not home.is_empty() else ""})
var in_sub := {}
var sub_dot := {}
for r: Dictionary in (doc["records"] as Array):
if String(r["kind"]) == "col.create":
sub_dot[String(r["dot"])] = _persist.record("col.create", {"name": String(r["name"]),
"parent": sub_dot.get(String(r["parent"]), top)})
for r2: Dictionary in (doc["records"] as Array):
if String(r2["kind"]) != "col.create":
continue
var ms: Array = []
for m: String in (r2["members"] as Array):
var l2 := m.substr(m.find(":") + 1)
in_sub[l2] = true
if fresh.has(l2):
ms.append(m.substr(0, m.find(":") + 1) + String(fresh[l2]))
if not ms.is_empty():
_persist.record("col.link", {"target": sub_dot[String(r2["dot"])], "members": ms})
var top_ms: Array = []
for r3: Dictionary in (doc["records"] as Array):
var l3 := String(r3["dot"])
if in_sub.has(l3):
continue
match String(r3["kind"]):
"curve.create":
if bool(r3.get("member", false)): # linked into the root (§16.2)
top_ms.append("c:" + String(fresh[l3]))
"shape.create":
top_ms.append("g:" + String(fresh[l3]))
"object.create":
if String(r3.get("parent", "")) == "":
top_ms.append("obj:" + String(fresh[l3]))
"brush.create":
var lg := String(r3["group"])
if not in_sub.has(lg) and not top_ms.has("g:" + String(fresh[lg])):
top_ms.append("g:" + String(fresh[lg]))
if not top_ms.is_empty():
_persist.record("col.link", {"target": top, "members": top_ms})
_persist.record("asset.remove", {"target": pdot})
_persist.end_gesture()
if _sel_asset == pdot:
_sel_asset = ""
_persist.refold()
_clear_face_selection()
print("[MapDCLevel] made placed asset %s real: %d record(s), collection \"%s\"" % [pdot, n, p["name"]])
return n
## Write a shape's new frame (and size). Refolds; returns true if recorded.
func _record_shape_frame(dot: String, frame: Transform3D, size: Variant = null) -> bool:
# A CABLE has no frame of its own (it is its curve's bounds): the move goes to the
# curve, ONE curve.set with every point carried by new frame · scale · old frame⁻¹
# (#120 L3 §11.3, "G on a cable moves its curve").
var st: Dictionary = _persist.shape_state(dot)
if String(st.get("type", "")) == "cable":
var cdot := String((st["params"] as Dictionary).get("curve", ""))
var cv: Dictionary = _persist.curve_states().get(cdot, {})
if cv.is_empty():
return false
var s0: Vector3 = st["size"]
var sc := Vector3.ONE
if size is Vector3:
sc = (size as Vector3) / s0.max(Vector3.ONE * 1.0e-6)
var d: Transform3D = frame * Transform3D(Basis.from_scale(sc), Vector3.ZERO) * (st["frame"] as Transform3D).affine_inverse()
return _persist.record("curve.set", {"target": cdot,
"points": LevelPersistence.CurveFold.to_wire(curve_points_by(cv["points"], d))}) != ""
var ok: bool = _persist.record("shape.xform", {"target": dot, "frame": SceneObjects.xform_to(frame)}) != ""
if size is Vector3:
var s: Vector3 = size
ok = _persist.record("shape.size", {"target": dot, "size": [s.x, s.y, s.z]}) != "" and ok
return ok
## ---- A SELECTED SHAPE'S FACE HANDLES (#120 §17.7 Q3a, TrenchBroom's) ----------------
## Six dots, one at each face's centre, while ONE shape is selected and no tool is up. A
## cable has no box of its own (its frame is its curve's bounds) and a placed asset is
## rigid: neither has handles.
const HANDLE_PX := 14.0
var _handle_view: MeshInstance3D = null
## [{id: "x+" | "x-" | …, pos, axis: 0..2, sign: ±1}], or [] when no shape has handles.
func shape_handles() -> Array:
var out: Array = []
if _sel_shape == "" or _persist == null or not GameMode.is_building():
return out
var st: Dictionary = _persist.shape_state(_sel_shape)
if st.is_empty() or String(st.get("type", "")) == "cable":
return out
var fr: Transform3D = st["frame"]
var sz: Vector3 = st["size"]
for i in 3:
for s: int in [1, -1]:
var c := sz * 0.5
c[i] = sz[i] if s > 0 else 0.0
out.append({"id": "%s%s" % ["xyz"[i], "+" if s > 0 else "-"], "pos": fr * c, "axis": i, "sign": s})
return out
## A press at screen point `at`: the nearest face handle within HANDLE_PX starts its drag.
## True if one did.
func press_shape_handle(at: Vector2) -> bool:
var hs := shape_handles()
var cam := get_viewport().get_camera_3d() if get_viewport() != null and not hs.is_empty() else null
if cam == null:
return false
var best: Dictionary = {}
var best_px := HANDLE_PX
for h: Dictionary in hs:
if cam.is_position_behind(h["pos"]):
continue
var px := cam.unproject_position(h["pos"]).distance_to(at)
if px < best_px:
best_px = px
best = h
if best.is_empty():
return false
_handle_op.setup(_sel_shape, int(best["axis"]), int(best["sign"]))
return _ensure_ops().activate(_handle_op)
## ONE gesture: a shape's new frame and size (a face handle's drag). True if recorded.
func set_shape_extent(dot: String, frame: Transform3D, size: Vector3) -> bool:
_ensure_persistence()
_persist.begin_gesture("Resize")
var ok := _record_shape_frame(dot, frame, size)
_persist.end_gesture()
_shapes_transformed(1 if ok else 0)
return ok
## The dots, redrawn when the selection or the shape changes.
func _refresh_shape_handles() -> void:
var hs := shape_handles()
if hs.is_empty() and _handle_view == null:
return
if _handle_view == null or not is_instance_valid(_handle_view):
_handle_view = MeshInstance3D.new()
_handle_view.name = "ShapeHandles"
_handle_view.mesh = ImmediateMesh.new()
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = Color(0.4, 0.85, 1.0)
m.no_depth_test = true
m.use_point_size = true
m.point_size = 11.0
_handle_view.material_override = m
_handle_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(_handle_view)
var im: ImmediateMesh = _handle_view.mesh
im.clear_surfaces()
if hs.is_empty():
return
im.surface_begin(Mesh.PRIMITIVE_POINTS)
for h: Dictionary in hs:
im.surface_add_vertex(h["pos"])
im.surface_end()
func _shapes_transformed(n: int) -> int:
if n > 0:
_persist.refold()
_reselect_shape()
_sel_point = _selection_centre()
return n
## Set some of a shape's parameters — one shape.set, one gesture (the Shape panel).
func set_shape_params(dot: String, params: Dictionary, label: String = "") -> bool:
_ensure_persistence()
var st: Dictionary = _persist.shape_state(dot)
if st.is_empty() or params.is_empty():
return false
var t: Object = ParamShapes.get_type(String(st["type"]))
_persist.begin_gesture(label if label != "" else "Edit %s" % (t.label() if t != null else "shape"))
var ok: bool = _persist.record("shape.set", {"target": dot, "params": params}) != ""
_persist.end_gesture()
if ok:
_persist.refold()
_reselect_shape()
return ok
## Texture a whole shape: ONE shape.set {target, tex} (one gesture, one Ctrl+Z). Returns
## how many solid planes now carry it (0 when the journal refused).
func set_shape_tex(dot: String, tex: Dictionary) -> int:
_ensure_persistence()
if (_persist.shape_state(dot) as Dictionary).is_empty():
return 0
_persist.begin_gesture("Texture shape")
var ok: bool = _persist.record("shape.set", {"target": dot, "params": {}, "tex": tex}) != ""
_persist.end_gesture()
if not ok:
return 0
_persist.refold()
_reselect_shape()
_note_recent_texture(String(tex.get("tex", "")))
var n := 0
for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
if not bool((level.get_brush(b) as Dictionary).get("clearance", false)):
n += (level.get_brush(b).get("planes", PackedFloat32Array()) as PackedFloat32Array).size() / 4
print("[texture] %s on shape %s (%d face(s))" % [tex.get("tex", ""), dot, n])
return n
## The last make_real's pieces: old piece id -> the dot of the plain brush it became
## (same planes, same order). What carries a selected part across (_q_make_real_then).
var last_real: Dictionary = {}
## MAKE PARAMETRIC (#120 F2, docs/node-tree-and-stacks.md §3.1): each selected plain brush
## becomes a parametric shape that generates it — `box` when it IS its own box in its local
## frame (size then edits it), else `hull` (its planes in the shape's unit box, each face
## keeping its texture). The frame is the brush's own basis (brush_local_basis) at its
## bounds' low corner. ONE gesture: shape.create + brush.remove per brush, and the shape is
## linked into the collections its brush's object was in. Make Real is the inverse. A
## shape's piece is refused (it is parametric already). Returns the new shape dots.
func make_parametric() -> Array[String]:
_ensure_persistence()
var made: Array[String] = []
var ids: Array = _sel_brushes.duplicate()
if ids.is_empty():
print("[MapDCLevel] make parametric: select brushes first")
return made
var tree: Dictionary = _persist.collection_tree()
_persist.begin_gesture("Make parametric")
for v: Variant in ids:
var b := int(v)
if shape_of_brush(b) != "":
continue
var d: Dictionary = level.get_brush(b)
var planes: PackedFloat32Array = d.get("planes", PackedFloat32Array())
var corners := FaceKit.corners(planes)
if corners.size() < 4:
continue
var basis := brush_local_basis(b).orthonormalized()
var bt := basis.transposed() # orthonormal: inverse = transpose
var lo := Vector3.INF
var hi := -Vector3.INF
for c: Vector3 in corners:
var lc := bt * c
lo = lo.min(lc)
hi = hi.max(lc)
var size := (hi - lo).max(Vector3.ONE * 1.0e-4)
var frame := Transform3D(basis, basis * lo)
# World plane n·x <= d with x = B (lo + S u) -> (S Bᵀn)·u <= d - (Bᵀn)·lo.
var unit: Array = []
var boxy := planes.size() == 24
for i in planes.size() / 4:
var ln := bt * Vector3(planes[i * 4], planes[i * 4 + 1], planes[i * 4 + 2])
var m := ln * size
var dd := planes[i * 4 + 3] - ln.dot(lo)
var l := m.length()
unit.append_array([m.x / l, m.y / l, m.z / l, dd / l])
var axial := absf(absf(ln.normalized().x) + absf(ln.normalized().y) + absf(ln.normalized().z) - 1.0) < 1.0e-4
boxy = boxy and axial
var payload := {"gv": 1, "frame": SceneObjects.xform_to(frame), "size": [size.x, size.y, size.z],
"material_id": int(d.get("material_id", 0)), "level_id": int(d.get("level_id", 0)), "tex": {}}
var ft: Array = d.get("face_tex", [])
if boxy and ft.all(func(t: Variant) -> bool: return str(t) == str(ft[0])):
payload["type"] = "box"
payload["params"] = {}
if not ft.is_empty() and ft[0] is Dictionary:
payload["tex"] = (ft[0] as Dictionary).duplicate(true)
else:
payload["type"] = "hull"
payload["params"] = {"planes": unit, "face_tex": ft.duplicate(true)}
var sd: String = _persist.record("shape.create", payload)
if sd == "":
continue
var g := String(level.get_brush_group(b))
var alone: bool = g != "" and (level.get_group_brush_ids(g) as PackedInt32Array).size() == 1
_persist.record("brush.remove", {"brush_key": String(level.get_brush_key(b))})
if g != "":
_link_copy(tree, "g:" + g, "g:" + sd)
if alone: # the shape REPLACES that object
for c: String in CollectionFold.collections_of(tree, "g:" + g):
_persist.record("col.unlink", {"target": c, "members": ["g:" + g]})
made.append(sd)
_persist.end_gesture()
if made.is_empty():
print("[MapDCLevel] make parametric: nothing to convert (a shape's piece is parametric already)")
return made
_persist.refold()
if made.size() == 1:
select_shape(made[0])
print("[MapDCLevel] made %d brush(es) parametric: %s" % [made.size(), str(made)])
return made
## ASSIGN A GENERATOR (#120 F2, #109 cube -> stairs): the shape `dot` becomes `type`, its
## current generator kept as the new one's PASSAGE (shape.set input) — a box drawn where the
## stairs go stays their air. Choosing the input's own type REMOVES the generator: the
## input is back as the shape and the passage is cleared. Params accumulate, so switching
## back to a type restores what it had. ONE gesture; Ctrl+Z returns to the previous type.
func assign_generator(dot: String, type: String) -> bool:
_ensure_persistence()
var st: Dictionary = _persist.shape_state(dot)
var t: Object = ParamShapes.get_type(type)
if st.is_empty() or t == null or String(st.get("type", "")) == type:
return false
var inp: Dictionary = st.get("input", {})
var payload := {"target": dot, "type": type, "gv": int(t.GV)}
if not inp.is_empty() and String(inp.get("type", "")) == type:
payload["input"] = {} # back to the input: the generator removed
payload["params"] = (inp.get("params", {}) as Dictionary).duplicate(true)
elif inp.is_empty():
payload["input"] = {"type": String(st["type"]),
"params": (st.get("params", {}) as Dictionary).duplicate(true)}
_persist.begin_gesture("Generator: %s" % t.label())
var ok: bool = _persist.record("shape.set", payload) != ""
_persist.end_gesture()
_persist.refold()
if _sel_shape == dot or ok:
select_shape(dot)
print("[MapDCLevel] %s is now %s" % [dot, type])
return ok
## ---- CURVES (#120 F5, CurveFold + CurveMath) -----------------------------------
## A curve is room state with no brushes: points with handles, tilt, radius and the
## surface normal they were placed on. Drawn as wire (CurveView); follow-curve, cables and
## flanges sample it with CurveMath. Each edit is one gesture.
## A new curve from CurveMath points; auto handles when `auto`. Its dot, or "".
func create_curve(points: Array, closed: bool = false, name: String = "Curve", auto: bool = true) -> String:
_ensure_persistence()
if points.size() < 2:
return ""
var pts: Array = CurveMath.auto_handles(points, closed) if auto else points
_persist.begin_gesture("Add curve")
var dot: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(pts),
"closed": closed, "name": name})
_persist.end_gesture()
_persist.refold()
return dot
## ADD A CABLE (#120 L3, §11.3): a curve through `points` and a cable shape on it, ONE
## gesture. `params` = the cable's (param_cable.gd; curve is filled in). Returns the
## shape's dot ("" when refused).
func add_cable(points: Array, closed: bool = false, params: Dictionary = {}, auto: bool = true) -> String:
_ensure_persistence()
if points.size() < 2:
return ""
_persist.begin_gesture("Add cable")
var dot := _record_cable(points, closed, params, auto)
_persist.end_gesture()
_persist.refold()
if dot != "":
select_shape(dot)
return dot
## One point's fields ({p: Vector3, tilt: float, …}) — what a drag writes.
## Moving `p` RE-DERIVES the handles of auto / vector points (#129 deferred bug: they kept
## their old stored handles and the spans bent): every point whose derived handles change
## gets its own per-point curve.set, in the same gesture. aligned / free keep theirs
## (CurveMath.auto_handles' rule).
func set_curve_point(dot: String, index: int, fields: Dictionary) -> void:
_ensure_persistence()
_persist.begin_gesture("Edit curve")
_persist.record("curve.set", {"target": dot, "index": index,
"fields": LevelPersistence.CurveFold.point_to_wire(fields)})
var cv: Dictionary = curve_states().get(dot, {})
if fields.has("p") and not fields.has("h_in") and not fields.has("h_out") and not cv.is_empty():
var pts: Array = cv["points"]
var derived: Array = CurveMath.auto_handles(pts, bool(cv["closed"]))
for i in pts.size():
var q: Dictionary = pts[i]
var d: Dictionary = derived[i]
if not (q["h_in"] as Vector3).is_equal_approx(d["h_in"]) or not (q["h_out"] as Vector3).is_equal_approx(d["h_out"]):
_persist.record("curve.set", {"target": dot, "index": i,
"fields": LevelPersistence.CurveFold.point_to_wire({"h_in": d["h_in"], "h_out": d["h_out"]})})
_persist.end_gesture()
_persist.refold()
func remove_curve(dot: String) -> void:
_ensure_persistence()
_persist.begin_gesture("Delete curve")
_persist.record("curve.remove", {"target": dot})
_persist.end_gesture()
_persist.refold()
func curve_states() -> Dictionary:
_ensure_persistence()
return _persist.curve_states()
## THE SELECTED CURVE (#120 §16.4): a curve is an object with no brushes, so it has a
## slot of its own, HELD like _sel_shape. Selecting one drops every other selection, and
## every other selection drops it (one selection at a time; see _sel_object).
## A setter, because every other selection CLEARS it in five places: each change redraws
## the curve view (deferred, once per frame) so the selected colour follows.
var _sel_curve := "":
set(v):
if v == "":
_sel_curves.clear() # dropping the active curve drops them all
if v != _sel_curve:
_sel_curve = v
if not _curve_redraw_queued:
_curve_redraw_queued = true
_redraw_curves_deferred.call_deferred()
var _curve_redraw_queued := false
## Every selected curve (§17.10), the active one (_sel_curve) last.
var _sel_curves: Array[String] = []
func _redraw_curves_deferred() -> void:
_curve_redraw_queued = false
_refresh_curves()
## Curve points carried by `d`: positions by d, handles by its basis, surface normals by
## the inverse-transpose (so a scale keeps them normal). Shared by G / R on a curve and
## by a cable's move (_record_shape_frame).
static func curve_points_by(points: Array, d: Transform3D) -> Array:
var nb := d.basis.inverse().transposed()
var pts: Array = []
for q: Dictionary in points:
var q2 := q.duplicate(true)
q2["p"] = d * (q["p"] as Vector3)
q2["h_in"] = d.basis * (q["h_in"] as Vector3)
q2["h_out"] = d.basis * (q["h_out"] as Vector3)
var n: Vector3 = q.get("n", Vector3.ZERO)
q2["n"] = (nb * n).normalized() if n.length() > 1.0e-6 else n
pts.append(q2)
return pts
## G / R ON A CURVE (§16.7 Q3): every point carried by `d`, ONE curve.set {points}, one
## gesture. The curve stays selected. True if recorded.
func transform_curve(dot: String, d: Transform3D, label: String = "Move curve") -> bool:
_ensure_persistence()
var cv: Dictionary = curve_states().get(dot, {})
if cv.is_empty():
return false
_persist.begin_gesture(label)
var ok: bool = _persist.record("curve.set", {"target": dot,
"points": LevelPersistence.CurveFold.to_wire(curve_points_by(cv["points"], d))}) != ""
_persist.end_gesture()
_persist.refold()
return ok
## The centre of a curve's points' bounds (G grabs it, R turns about it).
func curve_centre(dot: String) -> Vector3:
var pts: Array = curve_states().get(dot, {}).get("points", [])
if pts.is_empty():
return Vector3.ZERO
var bb := AABB(pts[0]["p"], Vector3.ZERO)
for q: Dictionary in pts:
bb = bb.expand(q["p"])
return bb.get_center()
## A curve's wire as segment pairs, carried by `d` (the G / R preview).
func curve_wire(dot: String, d: Transform3D = Transform3D.IDENTITY) -> PackedVector3Array:
var out := PackedVector3Array()
var c: Dictionary = curve_states().get(dot, {})
if c.is_empty():
return out
var pts: Array = c["points"]
var n := clampi(int(CurveMath.length(pts, bool(c["closed"])) / 0.25), 8, 512)
var fs := CurveMath.sample(pts, bool(c["closed"]), n)
for i in range(1, fs.size()):
out.append(d * (fs[i - 1]["p"] as Vector3))
out.append(d * (fs[i]["p"] as Vector3))
return out
## PICKING A CURVE BY ITS WIRE (§16.7 Q4). The wire is drawn over everything, so it is
## picked by ANGLE, not by a collider: the visible curve whose sampled wire passes nearest
## the ray, within PICK_CURVE_ANGLE (~9 px at 1080p / 70°; at least PICK_CURVE_MIN).
## {dot, dist} — dist along the ray, for _pick_element's nearer-wins — or {}. Hidden
## curves, and every curve while their overlay is off, cannot be picked.
const PICK_CURVE_ANGLE := 0.01
const PICK_CURVE_MIN := 0.05
func pick_curve(at: Variant = null) -> Dictionary:
if _persist == null or not _curves_visible():
return {}
var ray := _crosshair_ray(at)
if ray.is_empty():
return {}
var from: Vector3 = ray["from"]
var to: Vector3 = from + (ray["dir"] as Vector3) * _aim_reach()
var best := {}
var best_err := INF
var dots: Array = (_persist.curve_states() as Dictionary).keys()
dots.sort() # a tie goes the same way every time
for d: String in dots:
if _hidden_curves.has(d):
continue
var w := curve_wire(d)
for i in range(0, w.size(), 2):
var cp: PackedVector3Array = Geometry3D.get_closest_points_between_segments(from, to, w[i], w[i + 1])
var along := from.distance_to(cp[0])
var off := cp[0].distance_to(cp[1])
if off > maxf(PICK_CURVE_MIN, along * PICK_CURVE_ANGLE):
continue
var err := off / maxf(along, 1.0e-3)
if err < best_err:
best_err = err
best = {"dot": d, "dist": along}
return best
## HIDDEN CURVES (§16.4): view-local, set by the Outliner's apply() from its rows (the eye,
## H, a hidden collection or cable above). CurveView leaves them out.
var _hidden_curves := {}
func set_hidden_curves(dots: Array) -> void:
var h := {}
for d: Variant in dots:
h[String(d)] = true
if h == _hidden_curves:
return
_hidden_curves = h
_refresh_curves()
func is_curve_hidden(dot: String) -> bool:
return _hidden_curves.has(dot)
## Shift+D / Alt+D on a curve: a copy (curve.create, same points), linked into the same
## collections, one gesture; the copy is selected. A curve is its own data, so linked is
## the same as plain. Returns the copy's dot.
func duplicate_curve(dot: String) -> String:
_ensure_persistence()
var cv: Dictionary = curve_states().get(dot, {})
if cv.is_empty():
return ""
var tree: Dictionary = _persist.collection_tree()
_persist.begin_gesture("Duplicate")
var nd: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(cv["points"]),
"closed": bool(cv["closed"]), "name": String(cv["name"])})
if nd != "":
_link_copy(tree, "c:" + dot, "c:" + nd)
_persist.end_gesture()
_persist.refold()
if nd != "":
select_curve(nd)
return nd
## The reason the last delete refused (a used curve names its users, §16.7 Q2); the
## outliner shows it, gates read it.
var _last_refusal := ""
## X ON A CURVE: curve.remove, one gesture — REFUSED while a cable or a modifier uses it
## (§16.7 Q2), naming them. Returns 1 if deleted. (remove_curve is the unconditional
## low-level call the journal gates use.)
func delete_curve(dot: String) -> int:
_ensure_persistence()
_last_refusal = ""
if not curve_states().has(dot):
return 0
var users: Array = _persist.curve_users(dot)
if not users.is_empty():
var names: Array = users.map(func(d: String) -> String:
var t := String((_persist.shape_state(d) as Dictionary).get("type", ""))
return "%s %s" % [t if t != "" else "modifier", d])
_last_refusal = "the curve is used by %s — delete or re-point those first" % ", ".join(names)
print("[MapDCLevel] delete: %s" % _last_refusal)
return 0
_persist.begin_gesture("Delete curve")
_persist.record("curve.remove", {"target": dot})
_persist.end_gesture()
if _sel_curve == dot:
_clear_face_selection()
_persist.refold()
print("[MapDCLevel] deleted curve %s — Ctrl+Z brings it back" % dot)
return 1
func selected_curve() -> String:
return _sel_curve
## Rename a curve: one curve.set {name} (F2 / double-click on its outliner row).
func rename_curve(dot: String, name: String) -> void:
_ensure_persistence()
if not curve_states().has(dot) or name.strip_edges() == "":
return
_persist.begin_gesture("Rename curve")
_persist.record("curve.set", {"target": dot, "name": name.strip_edges()})
_persist.end_gesture()
_persist.refold()
## `add` (Shift, §17.10): toggle `dot` in the curve list and keep every other selection;
## the last one added is the active curve. Without it the selection is replaced.
func select_curve(dot: String, add: bool = false) -> void:
if add:
if dot == "" or not curve_states().has(dot):
return
var at := _sel_curves.find(dot)
if at >= 0:
_sel_curves.remove_at(at)
else:
_sel_curves.append(dot)
var keep := _sel_curves.duplicate()
_sel_curve = _sel_curves[-1] if not _sel_curves.is_empty() else ""
_sel_curves = keep
_announce_selection()
return
_clear_face_selection()
if dot == "" or not curve_states().has(dot):
return
# _sel_kind stays "": it names an ELEMENT or brush selection, and context_sections
# offers element ops (move in / out) for any kind that is set.
_sel_curve = dot
_sel_curves = [dot]
print("[MapDCLevel] selected curve %s" % dot)
func selected_curves() -> Array:
return _sel_curves.duplicate()
## A (2.79's select-all toggle, §17.10): anything selected -> nothing; else every visible
## object, every live brush and every curve, all held at once.
func select_all() -> void:
if not (_sel_brushes.is_empty() and _sel_objects.is_empty() and _sel_curves.is_empty()):
_clear_face_selection()
return
if level != null and level.has_method("get_live_brush_ids"):
select_brushes(level.get_live_brush_ids())
if _objects != null and is_instance_valid(_objects):
for e: Dictionary in (_objects.entries() as Array):
var n := e["node"] as Node3D
if n == null or n.is_visible_in_tree():
_sel_objects.append(String(e["dot"]))
_sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
var cds: Array = curve_states().keys()
cds.sort()
for d: String in cds:
if not _hidden_curves.has(d):
_sel_curves.append(d)
var keep := _sel_curves.duplicate()
_sel_curve = _sel_curves[-1] if not _sel_curves.is_empty() else ""
_sel_curves = keep
_announce_selection()
print("[MapDCLevel] selected all: %d brush(es), %d object(s), %d curve(s)" % [_sel_brushes.size(),
_sel_objects.size(), _sel_curves.size()])
## DROP TO THE GROUND (#120 L3 §14.3): Cablerator's Drop the Cable. Preloaded, not by class
## name: a new class_name is unknown until the next import refreshes the class cache.
const CurveDropScript := preload("res://scenes/maps/curves/curve_drop.gd")
const DropCableOperatorScript := preload("res://scenes/maps/operators/drop_cable_operator.gd")
## How far down a dropping point looks for ground.
const DROP_REACH := 200.0
var _drop_op: LevelOperator = null
## The selected cables: the active shape and the shapes of the selected brushes, by dot.
func selected_cables() -> Array:
_ensure_persistence()
var cands: Array = [_sel_shape] if _sel_shape != "" else []
for b: int in _sel_brushes:
cands.append(shape_of_brush(b))
var out: Array = []
for d: String in cands:
if d != "" and not out.has(d) and String(_persist.shape_state(d).get("type", "")) == "cable":
out.append(d)
return out
## Cable `dot`'s curve points as Drop would write them (the tool's preview reads this too);
## [] when it is not a cable on a live curve. A point rests the cable's radius above the hit
## — the whole bundle's when it has strands.
func dropped_points(dot: String, step: float) -> Array:
_ensure_persistence()
var st: Dictionary = _persist.shape_state(dot)
if String(st.get("type", "")) != "cable":
return []
var cv: Dictionary = _persist.curve_states().get(String((st["params"] as Dictionary).get("curve", "")), {})
if cv.is_empty():
return []
var p: Dictionary = ParamShapes.get_type("cable").resolve(st["params"])
var rest := float(p["radius"]) * ((1.0 + float(p["spread"])) if int(p["strands"]) > 1 else 1.0)
var own: Array = []
for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
own.append(level.get_brush(b).get("planes", PackedFloat32Array()))
return CurveDropScript.dropped(cv["points"], bool(cv.get("closed", false)), step, rest,
func(from: Vector3) -> Dictionary: return _drop_ray(from, own))
## Drop each cable in `dots`: ONE curve.set per cable, all in one gesture. How many wrote.
func drop_cables(dots: Array, step: float = 1.0) -> int:
_ensure_persistence()
var targets: Array = []
for d: Variant in dots:
var pts := dropped_points(String(d), step)
if pts.size() >= 2:
targets.append([String((_persist.shape_state(String(d))["params"] as Dictionary)["curve"]), pts])
var n := 0
_persist.begin_gesture("Drop to ground")
for t: Array in targets:
if _persist.record("curve.set", {"target": t[0], "points": LevelPersistence.CurveFold.to_wire(t[1])}) != "":
n += 1
_persist.end_gesture()
if n > 0:
_persist.refold()
_reselect_shape()
return n
## Straight down from `from` to the first surface that is not one of `own` (the dropping
## cable's pieces, as plane arrays): {position, normal}, or {} on a miss. Players are
## excluded as for every editor ray. A hit on the cable itself restarts 1 mm below it.
func _drop_ray(from: Vector3, own: Array) -> Dictionary:
var space := get_world_3d().direct_space_state
var excl: Array[RID] = []
var players := get_tree().root.get_node_or_null("Game/Players")
if players != null:
for pl in players.get_children():
if pl is CollisionObject3D:
excl.append((pl as CollisionObject3D).get_rid())
var end := from + Vector3.DOWN * DROP_REACH
var at := from
for _i in 64:
var q := PhysicsRayQueryParameters3D.create(at, end)
q.collide_with_areas = false
q.exclude = excl
var hit: Dictionary = space.intersect_ray(q)
if hit.is_empty():
return {}
var pos: Vector3 = hit["position"]
if not _inside_any(pos, own):
return {"position": pos, "normal": hit["normal"]}
at = pos + Vector3.DOWN * 0.001
if at.y <= end.y:
return {}
return {}
## CABLES FROM EDGES (#120 L3 §14.5). Preloaded for the same class-cache reason as Drop.
const CableEdgesOperatorScript := preload("res://scenes/maps/operators/cable_edges_operator.gd")
const EdgeCablesScript := preload("res://scenes/maps/curves/edge_cables.gd")
var _edges_op: LevelOperator = null
## A curve through `points` and a cable shape on it — RECORDS ONLY, inside the caller's
## gesture (add_cable and cables_from_edges write many in one). The shape's dot, or "".
func _record_cable(points: Array, closed: bool, params: Dictionary, auto: bool = true) -> String:
var pts: Array = CurveMath.auto_handles(points, closed) if auto else points
var cdot: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(pts),
"closed": closed, "name": "Cable"})
if cdot == "":
return ""
var p := params.duplicate(true)
p["curve"] = cdot
var t: Object = ParamShapes.get_type("cable")
return _persist.record("shape.create", {"type": "cable", "gv": t.GV, "params": p,
"frame": SceneObjects.xform_to(Transform3D.IDENTITY), "size": [1.0, 1.0, 1.0],
"material_id": 0, "level_id": 0, "tex": current_face_tex()})
## A cable per chain of `edges` (selected_edges()' form), ONE gesture; the new cables become
## the selection. Their dots ([] when there was nothing to make).
func cables_from_edges(edges: Array, centres: bool, offset: float, sharp: bool, params: Dictionary = {}) -> Array:
_ensure_persistence()
var out: Array = []
var chains: Array = EdgeCablesScript.chains(edges)
if chains.is_empty():
return out
_persist.begin_gesture("Cable from edges")
for ch: Dictionary in chains:
var pts: Array = EdgeCablesScript.curve_points(ch, centres, offset, sharp)
if pts.size() < 2:
continue
var d := _record_cable(pts, bool(ch["closed"]), params, false)
if d != "":
out.append(d)
_persist.end_gesture()
if out.is_empty():
return out
_persist.refold()
var ids := PackedInt32Array()
for d2: String in out:
ids.append_array(level.get_group_brush_ids(d2) as PackedInt32Array)
select_brushes(ids)
return out
## A BUNDLE BETWEEN TWO FACE SETS (#120 L3 §14.6). Preloaded for the class-cache reason.
const CableBundleOperatorScript := preload("res://scenes/maps/operators/cable_bundle_operator.gd")
const BundleCablesScript := preload("res://scenes/maps/curves/bundle_cables.gd")
var _bundle_op: LevelOperator = null
## The bundle on `faces` (_selected_face_polys()' form; exactly two islands) with options `o`
## (BundleCables.bundle): N curves + cables and a "Bundle" collection holding them (under
## Scene), ONE gesture; the cables become the selection. Their dots ([] when refused).
func cable_bundle(faces: Array, o: Dictionary, params: Dictionary = {}) -> Array:
_ensure_persistence()
var out: Array = []
var cables: Array = BundleCablesScript.bundle(faces, o)["cables"]
if cables.is_empty():
return out
_persist.begin_gesture("Cable bundle")
for pts: Array in cables:
var d := _record_cable(pts, false, params, false)
if d != "":
out.append(d)
if not out.is_empty():
var col: String = _persist.record("col.create", {"name": "Bundle", "parent": ""})
var members: Array = []
for d2: String in out:
members.append("g:" + d2)
if col != "":
_persist.record("col.link", {"target": col, "members": members})
_persist.end_gesture()
if out.is_empty():
return out
_persist.refold()
var ids := PackedInt32Array()
for d3: String in out:
ids.append_array(level.get_group_brush_ids(d3) as PackedInt32Array)
select_brushes(ids)
return out
## Is `p` inside (or on, within FaceKit.ON_PLANE) any of the convex plane sets?
static func _inside_any(p: Vector3, sets: Array) -> bool:
for pl: PackedFloat32Array in sets:
var inside := pl.size() >= 16
for k in range(0, pl.size(), 4):
if Vector3(pl[k], pl[k + 1], pl[k + 2]).dot(p) - pl[k + 3] > FaceKit.ON_PLANE:
inside = false
break
if inside:
return true
return false
var _curve_view: MeshInstance3D = null
var _curves_drawn: Array = [] # [curve states, hidden dots, selected dot] last drawn
## Every live curve as wire, sampled every ~0.25 m (8 .. 512 samples), its points as dots.
func _refresh_curves() -> void:
if _persist == null:
return
var curves: Dictionary = _persist.curve_states()
if curves.is_empty() and _curve_view == null:
return
if _curve_view == null or not is_instance_valid(_curve_view):
_curve_view = MeshInstance3D.new()
_curve_view.name = "CurveView"
_curve_view.mesh = ImmediateMesh.new()
_curves_drawn = [] # a new view has drawn nothing yet
# PER-SURFACE materials (not material_override, which would paint every surface the
# same): the selected curve is drawn white (§16.4), the rest gold.
_curve_mat = _curve_material(Color(1.0, 0.85, 0.3))
_curve_sel_mat = _curve_material(Color(1.0, 1.0, 1.0))
_curve_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_curve_view.visible = _curves_visible()
add_child(_curve_view)
# NOTHING TO REDRAW when what is drawn has not changed: the curves, which are hidden,
# which is selected. Every fold called this, and with a cable bundle it re-sampled
# every cable's curve to draw the same wire (48 ms per fold at 40 cables, 2026-10-20).
var drawn := [curves, _hidden_curves.keys(), _sel_curve]
if drawn == _curves_drawn and _curve_view.mesh != null:
return
_curves_drawn = drawn.duplicate(true)
var im: ImmediateMesh = _curve_view.mesh
im.clear_surfaces()
for dot: String in curves:
if _hidden_curves.has(dot):
continue # the eye / H (§16.4)
var c: Dictionary = curves[dot]
var mat: Material = _curve_sel_mat if dot == _sel_curve else _curve_mat
var pts: Array = c["points"]
var n := clampi(int(CurveMath.length(pts, bool(c["closed"])) / 0.25), 8, 512)
var fs := CurveMath.sample(pts, bool(c["closed"]), n)
if fs.size() < 2:
continue
im.surface_begin(Mesh.PRIMITIVE_LINE_STRIP, mat)
for f: Dictionary in fs:
im.surface_add_vertex(f["p"])
if bool(c["closed"]):
im.surface_add_vertex(fs[0]["p"])
im.surface_end()
im.surface_begin(Mesh.PRIMITIVE_POINTS, mat)
for q: Dictionary in pts:
im.surface_add_vertex(q["p"])
im.surface_end()
# HANDLES of every non-auto point (§6.1): a line to each end, the ends as dots.
var hl := PackedVector3Array()
for q2: Dictionary in pts:
if CurveMath.handle_type(q2) == "auto":
continue
var p2: Vector3 = q2["p"]
for hk: String in ["h_in", "h_out"]:
hl.append(p2)
hl.append(p2 + (q2[hk] as Vector3))
if not hl.is_empty():
im.surface_begin(Mesh.PRIMITIVE_LINES, mat)
for v: Vector3 in hl:
im.surface_add_vertex(v)
im.surface_end()
im.surface_begin(Mesh.PRIMITIVE_POINTS, mat)
for i in range(1, hl.size(), 2):
im.surface_add_vertex(hl[i])
im.surface_end()
var _curve_mat: Material = null
var _curve_sel_mat: Material = null
func _curve_material(col: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = col
m.no_depth_test = true
m.use_point_size = true
m.point_size = 7.0
return m
## How many surfaces draw the SELECTED curve (gates).
func curve_view_selected_surfaces() -> int:
if _curve_view == null or not is_instance_valid(_curve_view):
return 0
var im := _curve_view.mesh as ImmediateMesh
var n := 0
for i in im.get_surface_count():
if im.surface_get_material(i) == _curve_sel_mat:
n += 1
return n
## The number of curves drawn (gates).
func curve_view_surfaces() -> int:
return (_curve_view.mesh as ImmediateMesh).get_surface_count() if _curve_view != null and is_instance_valid(_curve_view) else 0
## ---- MODIFIERS (#120 F2, ModFold) ----------------------------------------------
## A shape's copy modifiers: mirror / array / radial / curve, each one gesture. Defaults:
## mirror along the frame's x at its low face (the copy alongside), array of 2 along x,
## radial of 4 about the frame's up axis through the 3D cursor, curve = follow the curve
## nearest the shape, fitted (count 0).
const MOD_DEFAULTS := {
"mirror": {"axis": "x", "offset": 0.0},
"array": {"count": 2, "offset": [1.0, 0.0, 0.0]},
"grid": {"count": [2, 1, 2], "offset": [1.0, 1.0, 1.0]},
"dice": {"count": [2, 1, 2]},
"radial": {"count": 4, "axis": "y"},
"curve": {"count": 0},
"chamfer": {"width": 0.1, "segments": 1, "angle": 30.0},
}
## The letter that picks each kind in Add modifier (Shift+Ctrl+M, then the letter; §12.5).
## C is curve, so chamfer takes B (BoxCutter's bevel key).
const MOD_KEYS := {"mirror": KEY_M, "array": KEY_A, "grid": KEY_G, "radial": KEY_R,
"curve": KEY_C, "chamfer": KEY_B, "dice": KEY_D}
func add_modifier(kind: String, shape: String = "", params: Dictionary = {}) -> String:
_ensure_persistence()
if shape == "":
shape = _sel_shape
if shape == "" or not MOD_DEFAULTS.has(kind) or not (_persist.live_shapes() as Dictionary).has(shape):
print("[MapDCLevel] add modifier: select a shape first (Make parametric turns a brush into one)")
return ""
var p: Dictionary = (MOD_DEFAULTS[kind] as Dictionary).duplicate(true)
if kind == "radial":
var c: Vector3 = (_cursor3d as Node3D).global_position if _cursor3d is Node3D else Vector3.ZERO
p["pivot"] = [c.x, c.y, c.z]
p.merge(params, true)
if kind == "curve" and String(p.get("curve", "")) == "":
p["curve"] = _nearest_curve(shape)
if String(p["curve"]) == "":
print("[MapDCLevel] add modifier: a curve modifier needs a curve (Add → Curve)")
return ""
_persist.begin_gesture("Modifier: %s" % kind.capitalize())
var dot: String = _persist.record("mod.create", {"target": shape, "mod": kind, "params": p})
_persist.end_gesture()
_persist.refold()
select_shape(shape)
return dot
## The live curve with a point nearest the shape's centre ("" when the room has none).
func _nearest_curve(shape: String) -> String:
var st: Dictionary = _persist.shape_state(shape)
var c: Vector3 = (st["frame"] as Transform3D) * ((st["size"] as Vector3) * 0.5) if not st.is_empty() else Vector3.ZERO
var best := INF
var out := ""
var curves: Dictionary = _persist.curve_states()
var dots: Array = curves.keys()
dots.sort() # a tie goes the same way on every peer
for d: String in dots:
for q: Dictionary in curves[d]["points"]:
var dd := (q["p"] as Vector3).distance_to(c)
if dd < best:
best = dd
out = d
return out
func set_modifier(dot: String, params: Dictionary = {}, on: Variant = null) -> void:
_ensure_persistence()
var payload := {"target": dot}
if not params.is_empty():
payload["params"] = params
if on != null:
payload["on"] = bool(on)
_persist.begin_gesture("Edit modifier")
_persist.record("mod.set", payload)
_persist.end_gesture()
_persist.refold()
func remove_modifier(dot: String) -> void:
_ensure_persistence()
_persist.begin_gesture("Remove modifier")
_persist.record("mod.remove", {"target": dot})
_persist.end_gesture()
_persist.refold()
## The shape's modifiers that still exist (on or off), in stack order (ModFold states).
func modifier_stack(shape: String) -> Array:
_ensure_persistence()
return LevelPersistence.ModFold.stack_of(_persist.records(false), shape)
## The generator picker's rows for the selected shape: every create-tool type, plus the
## input's own type (which removes the generator) when one is assigned.
func generator_choices() -> Array:
var rows: Array = []
var dot := _sel_shape
if dot == "":
return rows
var st: Dictionary = _persist.shape_state(dot)
var inp := String((st.get("input", {}) as Dictionary).get("type", ""))
if inp != "":
var ti: Object = ParamShapes.get_type(inp)
rows.append(["Remove generator (back to %s)" % (ti.label() if ti != null else inp),
func() -> void: assign_generator(dot, inp)])
for tid: String in ParamShapes.ids():
if tid == String(st.get("type", "")) or tid == inp:
continue # inp: that row is "Remove generator"
var t: Object = ParamShapes.get_type(tid)
rows.append([t.label(), func() -> void: assign_generator(dot, tid)])
return rows
## MAKE REAL (§6, "Apply modifier"): the shape's current pieces become plain brushes —
## brush.create records with fresh dots, one new group — and the shape stops generating
## (shape.detach). ONE gesture: Ctrl+Z brings the shape back. Returns the piece count.
func make_real(dot: String) -> int:
_ensure_persistence()
if not (_persist.live_shapes() as Dictionary).has(dot):
return 0
# A LIVE CUTTER is a tool: baking it would turn the wire into solid brushes standing
# in the hole it cuts, and leave its op cutting with nothing.
if LevelPersistence.OpFold.cutter_dots(_persist.records(false)).has(dot):
print("[MapDCLevel] %s is a live cutter — Make Real its TARGET (or undo the cut) instead" % dot)
return 0
var ids: PackedInt32Array = level.get_group_brush_ids(dot)
_persist.begin_gesture("Make Real")
var group := ""
var n := 0
last_real.clear()
for id: int in ids:
var payload: Dictionary = _persist._brush_payload(level.get_brush(id))
var nd: String = _persist.reserve_dot()
if group == "":
group = nd
payload["group"] = group
if _persist.record_at(nd, "brush.create", payload):
n += 1
last_real[id] = nd
_persist.record("shape.detach", {"target": dot})
_retire_ops_on(dot) # its cuts are baked into the pieces above
_persist.end_gesture()
if _sel_shape == dot:
_sel_shape = ""
_persist.refold()
select_brushes(level.get_group_brush_ids(group))
print("[MapDCLevel] made %s real: %d brush(es)" % [dot, n])
return n
func select_brushes(ids: PackedInt32Array) -> void:
_sel_shape = ""
_sel_asset = ""
_sel_curve = ""
_sel_object = "" # one selection at a time; see _sel_object
_sel_objects.clear()
_announce_selection()
_sel_brushes.clear()
for b: int in ids:
_sel_brushes.append(b)
_sel_brush = _sel_brushes[-1] if not _sel_brushes.is_empty() else -1
# A multi-brush selection has no single face or element, so the per-element
# highlight is cleared rather than left pointing at whatever was picked last.
_sel_keys = PackedStringArray()
_sel_faces.clear()
_sel_edges.clear()
# "brush", NOT "". MoveOperator gates its selection branch on
# `_sel_kind == "brush"` (move_operator.gd on_activate), so an empty kind made
# G ignore the outliner entirely and fall through to the crosshair — which
# refuses outright when you are not aiming at geometry. Selecting a passage
# and pressing G did nothing, which is exactly what it looked like.
_sel_kind = "brush" if not _sel_brushes.is_empty() else ""
# WHERE THE DRAG GRABS. The centre of what was selected, because a selection
# made from a panel has no click point — and MoveOperator measures its drag
# from _sel_point, so leaving it stale grabs wherever you last clicked and the
# first frame jumps.
_sel_point = _selection_centre()
_update_face_highlight()
_update_element_highlight()
var v := _ensure_brush_view()
v.set_aimed(_sel_brush)
print("[MapDCLevel] outliner selected %d brush(es), active #%d"
% [_sel_brushes.size(), _sel_brush])
## MOVE EVERY SELECTED BRUSH, journalled, one rebuild at the end.
##
## Extracted from MoveOperator.confirm so the operator and the organizer's
## numeric fields drive the SAME path. Two writers of a journal path is how they
## come to disagree about what a record means — and this one already has two
## callers on the day it is written.
##
## Returns how many actually moved: the store refuses a translation that would
## empty a brush, and a partial move is reported rather than rolled back, for the
## same reason CreateBoxOperator reports a partial create.
## ROTATE `ids` by `deg` about `axis` through ONE shared `pivot` (RotateOperator).
## One brush.rotate per brush, pivot carried in the record for the same reason as
## scale_selection's: a replaying peer must turn the group about the author's point.
## Texture lock as everywhere (libdc freezes locked faces to explicit). One rebuild.
func rotate_brushes(ids: Array, axis: Vector3, deg: float, pivot: Vector3) -> int:
if ids.is_empty() or level == null or is_zero_approx(deg):
return 0
_ensure_persistence()
# A SHAPE turns its FRAME about the pivot (shape.xform); its pieces regenerate.
var sp: Array = _split_shapes(ids)
# A PLACED ASSET turns its frame the same way (asset.xform, #120 L4).
if not (sp[2] as Dictionary).is_empty():
var ra := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO)
var about_a := Transform3D(Basis.IDENTITY, pivot) * ra * Transform3D(Basis.IDENTITY, -pivot)
var na := 0
for pd: String in (sp[2] as Dictionary):
if _record_asset_frame(pd, about_a * _asset_frame(pd)):
na += 1
if (sp[0] as Dictionary).is_empty() and (sp[1] as Array).is_empty():
return _shapes_transformed(na)
_shapes_transformed(na)
ids = sp[1] # plain brushes only below: never a placement's piece
if not (sp[0] as Dictionary).is_empty():
var r := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO)
var about := Transform3D(Basis.IDENTITY, pivot) * r * Transform3D(Basis.IDENTITY, -pivot)
var ns := 0
for d: String in (sp[0] as Dictionary):
var st: Dictionary = _persist.shape_state(d)
if _record_shape_frame(d, about * (st["frame"] as Transform3D)):
ns += 1
if (sp[1] as Array).is_empty():
return _shapes_transformed(ns)
_shapes_transformed(ns)
ids = sp[1]
var turned := 0
for b: int in ids:
if not level.rotate_brush(b, axis, deg, pivot, tex_lock):
continue
_persist.record("brush.rotate", {
"brush_key": level.get_brush_key(b),
"axis": [axis.x, axis.y, axis.z],
"angle": deg,
"pivot": [pivot.x, pivot.y, pivot.z],
"tex_lock": tex_lock,
})
turned += 1
if turned == 0:
return 0
level.rebuild()
_refresh_brush_view()
_sel_point = _selection_centre()
return turned
func translate_selection(delta: Vector3) -> int:
if _sel_brushes.is_empty() or level == null or delta == Vector3.ZERO:
return 0
_ensure_persistence()
# A SHAPE moves its FRAME (shape.xform); its pieces regenerate where it now is.
var ids: Array = _sel_brushes.duplicate()
var sp: Array = _split_shapes(ids)
# A PLACED ASSET moves its frame too (asset.xform, #120 L4).
var na := 0
for pd: String in (sp[2] as Dictionary):
var fa := _asset_frame(pd)
if _record_asset_frame(pd, Transform3D(fa.basis, fa.origin + delta)):
na += 1
if na > 0 and (sp[0] as Dictionary).is_empty() and (sp[1] as Array).is_empty():
return _shapes_transformed(na)
ids = sp[1] # plain brushes only below: never a placement's piece
if not (sp[0] as Dictionary).is_empty():
var ns := 0
for d: String in (sp[0] as Dictionary):
var f: Transform3D = _persist.shape_state(d)["frame"]
if _record_shape_frame(d, Transform3D(f.basis, f.origin + delta)):
ns += 1
if (sp[1] as Array).is_empty():
return _shapes_transformed(ns)
var keep_shape := _sel_shape
_shapes_transformed(ns)
_sel_shape = keep_shape
ids = sp[1]
var moved := 0
for b: int in ids:
if not level.translate_brush(b, delta, tex_lock):
continue
_persist.record("brush.translate", {
"brush_key": level.get_brush_key(b),
"delta": [delta.x, delta.y, delta.z],
"tex_lock": tex_lock,
})
moved += 1
if moved == 0:
return 0
level.rebuild()
_refresh_brush_view()
_sel_point = _selection_centre()
return moved
## SCALE EVERY SELECTED BRUSH about one shared pivot — hyper-games #28, "if the
## length changes the cut should change with it".
##
## THE PIVOT IS SHARED AND PASSED, not each brush's own centre. Scaling every
## piece about itself leaves them the same distance apart and the arch comes
## apart; about the selection's centre they stay one object. It is journalled
## with the record for the same reason: a replaying peer must scale about the
## point the author used, and "the group's centre" is a function of the whole
## selection rather than of any one brush.
##
## THE CUT FOLLOWS BY CONSTRUCTION. A clearance volume is in the group, so it is
## scaled by the identical factor about the identical pivot — the ring cannot get
## longer than the bore that serves it. That is the whole reason this is a group
## operation rather than a per-brush one.
func scale_selection(factor: Vector3, pivot: Vector3) -> int:
return scale_brushes(_sel_brushes, factor, pivot)
## LOCAL SCALE (#109, user 2026-10-13: Scale's X X; "local = shapes only"): each shape's
## SIZE scales by `f` along its OWN axes, and its frame origin moves by M = B·S·Bᵀ about
## the pivot — exact for any frame, turned or not. A plain brush cannot take this (a
## world-axis brush.scale is all the store has), so ScaleOperator only offers local on
## shapes; plain pieces in `ids` are left alone here.
func scale_shapes_local(ids: Array, f: Vector3, pivot: Vector3) -> int:
if ids.is_empty() or f.is_equal_approx(Vector3.ONE) or f.x <= 0.0 or f.y <= 0.0 or f.z <= 0.0:
return 0
_ensure_persistence()
var sp: Array = _split_shapes(ids)
if not (sp[2] as Dictionary).is_empty():
print("[MapDCLevel] scale: a placed asset is rigid (#120 L4 §15.2) — left as it is; Make Real it to scale its parts")
var ns := 0
for d: String in (sp[0] as Dictionary):
var st: Dictionary = _persist.shape_state(d)
var fr: Transform3D = st["frame"]
var size: Vector3 = st["size"]
var b := fr.basis.orthonormalized()
var m: Basis = b * Basis.from_scale(f) * b.inverse()
var o: Vector3 = pivot + m * (fr.origin - pivot)
if _record_shape_frame(d, Transform3D(fr.basis, o), size * f):
ns += 1
return _shapes_transformed(ns)
## The SIZE of the shape a brush is a piece of (its own axes), or ZERO for a plain brush.
func shape_local_size(b: int) -> Vector3:
var sd := shape_of_brush(b)
if sd == "":
return Vector3.ZERO
var st: Dictionary = _persist.shape_state(sd)
return st.get("size", Vector3.ZERO)
## scale_selection's body for ANY set of brushes — ScaleOperator scales an aimed brush
## that is not selected, exactly as RotateOperator turns one (rotate_brushes).
func scale_brushes(ids: Array, factor: Vector3, pivot: Vector3) -> int:
if ids.is_empty() or level == null:
return 0
if absf(factor.x) < 1.0e-4 or absf(factor.y) < 1.0e-4 or absf(factor.z) < 1.0e-4:
return 0 # a zero factor is not undoable by scaling back
if factor.is_equal_approx(Vector3.ONE):
return 0
_ensure_persistence()
# A SHAPE scales by its SIZE (shape.size) and frame origin (shape.xform) — exact for
# an AXIS-ALIGNED frame: the corner maps to pivot + f*(corner - pivot) and each local
# extent scales by the factor of the world axis it lies on. A turned frame cannot
# take a non-uniform world scale as a size; it is refused, not approximated.
var sp: Array = _split_shapes(ids)
if not (sp[2] as Dictionary).is_empty():
print("[MapDCLevel] scale: a placed asset is rigid (#120 L4 §15.2) — left as it is; Make Real it to scale its parts")
ids = sp[1] # plain brushes only below: never a placement's piece
if not (sp[0] as Dictionary).is_empty():
var ns := 0
for d: String in (sp[0] as Dictionary):
var st: Dictionary = _persist.shape_state(d)
var f: Transform3D = st["frame"]
var size: Vector3 = st["size"]
var new_size := size
var aligned := factor.x > 0.0 and factor.y > 0.0 and factor.z > 0.0
for i in 3:
var col: Vector3 = f.basis[i]
var w := -1
for j in 3:
if absf(absf(col[j]) - col.length()) < 1.0e-4:
w = j
if w < 0:
aligned = false
break
new_size[i] = size[i] * factor[w]
if not aligned:
print("[MapDCLevel] shape %s is turned off-axis — Make Real to scale its pieces" % d)
continue
var o: Vector3 = pivot + (f.origin - pivot) * factor
if _record_shape_frame(d, Transform3D(f.basis, o), new_size):
ns += 1
if (sp[1] as Array).is_empty():
return _shapes_transformed(ns)
_shapes_transformed(ns)
ids = sp[1]
var scaled := 0
for b: int in ids:
# A refusal is per brush and is not fatal: the store rejects a scale that
# would leave a brush enclosing nothing, and reporting a partial is better
# than rolling back geometry other peers may already have replayed.
if not level.scale_brush(b, factor, pivot, tex_lock):
continue
# FLAT FLOATS, like every other vector in this journal (`delta`, `pos`,
# `half`). An engine type in a durable record is one more thing that has
# to keep surviving var_to_str across builds.
_persist.record("brush.scale", {
"brush_key": level.get_brush_key(b),
"scale": [factor.x, factor.y, factor.z],
"pivot": [pivot.x, pivot.y, pivot.z],
"tex_lock": tex_lock,
})
scaled += 1
if scaled == 0:
return 0
level.rebuild()
_refresh_brush_view()
_sel_point = _selection_centre()
return scaled
## One grid step in metres — public, because the organizer ratchets its size
## fields to it and reaching into _grid_step from a panel is how the two drift.
func grid_step() -> float:
return _grid_step()
## The grid, in QUAKE UNITS — 8, 16, 32… This is the vocabulary the rest of the
## editor already speaks: the create tool's status line reads "152 units" and the
## .MAP was authored on an integer lattice of them. Metres are the engine's
## internal scale and nobody building a level thinks in 0.224 of one.
##
## THE EFFECTIVE STEP (E2): every operator and placement snap reads this, so the snap
## toggle, Ctrl-invert and Shift-precision reach all of them through one function.
func grid_units() -> float:
var u: float = base_grid_units() if snap_active() else 1.0
if _tool_up() and Input.is_key_pressed(KEY_SHIFT):
u /= PRECISION
return u
## Metres per Quake unit. Exposed so the organizer can show units without
## hard-coding the constant a second time.
func world_scale() -> float:
return WORLD_SCALE
## The selection's bounding box, for the organizer's size fields.
## One narrow read (DCLevel.get_brushes_bounds), not a get_brush per brush: the organizer
## asks on every fold, and a 668-brush selection cost ~80 ms that way (#127).
func selection_size() -> Vector3:
if _sel_brushes.is_empty() or level == null:
return Vector3.ZERO
var bb: Dictionary = level.get_brushes_bounds(PackedInt32Array(_sel_brushes))
return ((bb["aabb_max"] as Vector3) - (bb["aabb_min"] as Vector3)) if int(bb["count"]) > 0 else Vector3.ZERO
## Select every live brush in the level — the organizer's Select all.
func select_all_brushes() -> void:
if level == null or not level.has_method("get_live_brush_ids"):
return
select_brushes(level.get_live_brush_ids())
## The centre of the current brush selection — public, because the organizer's
## position fields read it and write back a delta against it.
func selection_centre() -> Vector3:
return _selection_centre()
## The centre of the current brush selection, for a drag that began in a panel.
func _selection_centre() -> Vector3:
if _sel_brushes.is_empty() or level == null:
return Vector3.ZERO
# The mean of the brushes' centres, as get_brush's "center" summed (#127: one read).
return level.get_brushes_bounds(PackedInt32Array(_sel_brushes))["center"]
func _clear_face_selection() -> void:
_sel_shape = ""
_sel_asset = ""
_sel_curve = ""
_sel_object = ""
_sel_objects.clear()
_announce_selection()
_sel_brushes.clear()
_sel_brush = -1
_sel_keys = PackedStringArray()
_sel_faces.clear()
_sel_edges.clear()
_sel_kind = ""
_update_face_highlight()
_update_element_highlight()
print("[MapDCLevel] selection cleared")
## Add or replace, and make it active. Clicking a brush that is already in the set
## with `add` REMOVES it — the same toggle every editor uses, so a mis-shift-click is
## undone by repeating it rather than by starting the selection over.
func _note_selected(brush: int, add: bool) -> void:
# ONE SELECTION AT A TIME (see _sel_object): picking a brush drops a selected
# object. It did not — select an object in the Outliner, click a wall, press G,
# and the OBJECT moved, because MoveOperator checks _sel_object first.
_announce_selection()
if not add:
_sel_shape = ""
_sel_asset = ""
_sel_curve = ""
_sel_object = ""
_sel_objects.clear()
_sel_brushes = [brush]
_sel_brush = brush
return
# THE UNION (§17.10): Shift keeps the objects and curves already held (a shape or a
# placement held as ONE thing becomes plain brushes once one is toggled).
_sel_shape = ""
_sel_asset = ""
var at: int = _sel_brushes.find(brush)
if at >= 0:
_sel_brushes.remove_at(at)
# Deselecting the active one hands active to whatever is left, or to nothing.
if _sel_brush == brush:
_sel_brush = _sel_brushes[-1] if not _sel_brushes.is_empty() else -1
return
_sel_brushes.append(brush)
_sel_brush = brush
## Which planes contain this point? That IS the incidence test — no adjacency
## structure is needed, because a face/edge/vertex simply is the set of planes
## whose surface the point lies on.
func _planes_through(brush: int, p: Vector3, eps: float) -> PackedInt32Array:
var out := PackedInt32Array()
var pl: PackedFloat32Array = level.get_brush(brush).get("planes", PackedFloat32Array())
for i in range(pl.size() / 4):
if not level.face_is_live(brush, i):
continue
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
if absf(n.dot(p) - pl[i * 4 + 3]) < eps:
out.append(i)
return out
## The selected face, drawn. A print is not a selection — you cannot aim at a
## wall and know which of its faces the editor thinks you picked, and with the
## nudge moving one plane at a time that ambiguity is the whole interaction.
##
## Parented to the DCLevel node, not to the map: the winding comes back in the
## store's own space, so anything else would need the transform applied by hand
## and would drift the moment the level node is moved.
func _ensure_highlight() -> MeshInstance3D:
if _hl != null and is_instance_valid(_hl):
return _hl
_hl = MeshInstance3D.new()
_hl.name = "FaceHighlight"
_hl.mesh = ImmediateMesh.new()
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
# A LIGHT, DEPTH-TESTED tint (docs/editor-texturing-selection-move.md §3). It was
# cyan 0.35, double-sided and drawn through walls — ~58 % over the texture on a
# box, so the thing being edited could not be seen. The outline (SelectionOutline)
# carries the selection now; this only marks WHICH face, and leaves it readable.
# Coplanar z-fighting is avoided by lifting the polygon 1 mm off the face
# (_update_face_highlight), not by switching depth testing off.
m.albedo_color = Color(1.0, 0.72, 0.25, 0.12)
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.no_depth_test = false
_hl.material_override = m
_hl.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_hl.visible = _highlights_on()
level.add_child(_hl)
return _hl
## The SELECTED ELEMENT, in its own shape: a point for a vertex, a line for an edge,
## the polygon for a face.
##
## The incident-face wash below is still drawn, and is still honest — it is what a
## nudge will move, all of it, bodily. But it answered the wrong question on its own:
## selecting a corner lit up three faces, so "vertex" and "face" looked identical and
## the only way to know which you had was to read a print. This draws the thing you
## picked, over the thing it will move.
func _update_element_highlight() -> void:
if level == null or not is_instance_valid(level):
return
if _sel_elem == null or not is_instance_valid(_sel_elem):
_sel_elem = MeshInstance3D.new()
_sel_elem.name = "SelectedElement"
_sel_elem.mesh = ImmediateMesh.new()
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(0.2, 1.0, 0.6, 1.0)
m.no_depth_test = true # you must be able to see what you selected
m.use_point_size = true
m.point_size = 11.0 # bigger than a handle: this one is CHOSEN
_sel_elem.material_override = m
_sel_elem.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_sel_elem.visible = _highlights_on()
level.add_child(_sel_elem)
var im: ImmediateMesh = _sel_elem.mesh
im.clear_surfaces()
var ol := _ensure_outline()
for layer: String in ["active", "selected", "face", "element"]:
ol.clear_layer(layer)
if _sel_brush < 0 or _sel_kind == "":
return
match _sel_kind:
"vertex":
im.surface_begin(Mesh.PRIMITIVE_POINTS)
im.surface_add_vertex(_sel_point)
im.surface_end()
"edge":
# Every edge in the set (§14.4); the active one alone when the set is empty.
var segs := edge_set_lines()
if segs.is_empty():
segs = PackedVector3Array([_sel_edge_a, _sel_edge_b])
ol.set_layer("element", segs, Color(0.2, 1.0, 0.6), 3.0, 0.4, 1.5)
"brush":
# OUTLINES ARE THE WHOLE GRAMMAR (docs/editor-texturing-selection-move.md §3):
# every selected brush outlined orange; the ACTIVE one — the operand, the
# CSG target — brighter and thicker. No fill anywhere.
var others := PackedVector3Array()
for sb: int in _sel_brushes:
if sb != _sel_brush:
others.append_array(level.get_brush_edges_of(sb))
ol.set_layer("selected", others, OUTLINE_SELECTED, 2.0, 0.3, 1.0)
ol.set_layer("active", level.get_brush_edges_of(_sel_brush), OUTLINE_ACTIVE, 3.0, 0.35, 1.5)
"face":
# The face's boundary, thick, plus the light tint from _update_face_highlight.
var face: int = level.face_by_key(_sel_brush, _sel_keys[0]) if not _sel_keys.is_empty() else -1
if face < 0:
return
var pts: PackedVector3Array = level.get_face_winding(_sel_brush, face)
if pts.size() < 3:
return
var seg := PackedVector3Array()
for i in range(pts.size()):
seg.append(pts[i])
seg.append(pts[(i + 1) % pts.size()])
ol.set_layer("face", seg, OUTLINE_FACE, 3.0, 0.35, 1.5)
## Selection colours — Blender / Godot's orange family (user decision 2026-10-03).
## THE SELECTED OBJECT, outlined like a brush: a box at its world bounds (a model's
## meshes, or the small stand-in box a light gets — SceneObjects.bounds_of). Polled,
## because an object moves without the selection changing (G / R preview, a peer's
## edit, the model arriving), and redrawn only when the box actually changes.
## Layer "object" = the ACTIVE object (bright, thick); "objects" = the rest of the
## selection (the brush selection's colour) — the same two-tone rule brushes use.
var _obj_outline_sig := ""
func _update_object_outline() -> void:
var active := PackedVector3Array()
var others := PackedVector3Array()
var sig := ""
if _outline != null or not _sel_objects.is_empty():
for d: String in _sel_objects:
var n := object_node(d) as Node3D
if n == null:
continue
var box := SceneObjects.bounds_of(n)
sig += "%s%s;" % [d, box]
var seg := _box_segments(box)
if d == _sel_object:
active = seg
else:
others.append_array(seg)
if sig == _obj_outline_sig:
return
_obj_outline_sig = sig
var ol := _ensure_outline()
ol.set_layer("object", active, OUTLINE_ACTIVE, 3.0, 0.35, 1.5)
ol.set_layer("objects", others, OUTLINE_SELECTED, 2.0, 0.3, 1.0)
## A world AABB as 12 edges in the outline's space.
func _box_segments(box: AABB) -> PackedVector3Array:
var inv := _ensure_outline().global_transform.affine_inverse()
var c: Array = []
for i in 8:
c.append(inv * (box.position + box.size * Vector3(i & 1, (i >> 1) & 1, (i >> 2) & 1)))
var seg := PackedVector3Array()
for e: Array in [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]:
seg.append(c[e[0]])
seg.append(c[e[1]])
return seg
const OUTLINE_SELECTED := Color("#ed5700")
const OUTLINE_ACTIVE := Color("#ffa028")
const OUTLINE_FACE := Color("#ffb700")
var _outline: SelectionOutline = null
func _ensure_outline() -> SelectionOutline:
if _outline != null and is_instance_valid(_outline):
return _outline
_outline = SelectionOutline.new()
_outline.name = "SelectionOutline"
_outline.visible = _highlights_on()
level.add_child(_outline)
return _outline
## HIGHLIGHTS ARE AN EDITING AID (2026-10-05: "upon exiting editor mode no element
## highlight should remain"). Leaving build mode hid the wireframe but left the orange
## selection outlines, the selected element and the face tint drawn over the world you
## went back to play in. HIDDEN, not cleared: the selection is kept, so coming back to
## build shows what you had. A node made while playing starts hidden (_highlights_on).
func _highlights_on() -> bool:
return GameMode == null or GameMode.current == GameMode.BUILD
func _show_highlights(on: bool) -> void:
for n: Variant in [_outline, _sel_elem, _hl]:
if n is Node3D and is_instance_valid(n):
(n as Node3D).visible = on
var _sel_elem: MeshInstance3D = null
func _update_face_highlight() -> void:
if level == null or not is_instance_valid(level):
return
var hl := _ensure_highlight()
var im: ImmediateMesh = hl.mesh
im.clear_surfaces()
if _sel_brush < 0 or _sel_keys.is_empty() or not level.has_method("get_face_winding"):
return
# ONLY WHERE A FACE IS WHAT YOU PICKED. The wash lights every incident face, so a
# vertex lit three and an edge lit two — which is honest about what a nudge MOVES
# but answers a question you did not ask, and drowns the element you actually
# selected. Vertex mode shows a vertex, edge mode an edge; nothing else.
# BRUSHES ARE OUTLINED, NEVER WASHED: a brush selection's planes are every face of
# it, and filling them is exactly what hid the brush (user 2026-10-03).
if _sel_kind != "face":
return
var planes: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
# EVERY incident face, because the selection IS its incident planes: a vertex
# lights three faces, an edge two, a face one. That is also the honest preview
# of what a nudge will move — all of them, bodily.
im.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
var drawn := 0
for k: String in _sel_keys:
var face: int = level.face_by_key(_sel_brush, k)
if face < 0:
continue
var pts: PackedVector3Array = level.get_face_winding(_sel_brush, face)
if pts.size() < 3:
continue
# 1 mm off the face along its outward normal, so the depth-tested tint never
# z-fights with the surface it marks.
var lift := Vector3.ZERO
if face * 4 + 2 < planes.size():
lift = Vector3(planes[face * 4], planes[face * 4 + 1], planes[face * 4 + 2]).normalized() * 0.001
# A triangle fan is EXACT here, not an approximation: a brush face is the
# intersection of half-spaces and is therefore convex by construction.
for i in range(1, pts.size() - 1):
im.surface_add_vertex(pts[0] + lift)
im.surface_add_vertex(pts[i] + lift)
im.surface_add_vertex(pts[i + 1] + lift)
drawn += 1
# The rest of the cross-brush face set (the active face is drawn above; drawing it
# twice would double its alpha and make it read as special).
var active_face: int = level.face_by_key(_sel_brush, _sel_keys[0]) if _sel_keys.size() == 1 else -1
for p: Dictionary in _selected_face_polys():
if int(p["brush"]) == _sel_brush and int(p["face"]) == active_face:
continue
var fp: PackedVector3Array = p["pts"]
var fl: Vector3 = (p["n"] as Vector3) * 0.001
for i in range(1, fp.size() - 1):
im.surface_add_vertex(fp[0] + fl)
im.surface_add_vertex(fp[i] + fl)
im.surface_add_vertex(fp[i + 1] + fl)
drawn += 1
im.surface_end()
if drawn == 0:
im.clear_surfaces()
## Resolve the crosshair to a brush and to the ELEMENT under it — vertex, edge or
## face — by proximity, so one aim serves all three. Cepero's generality
## principle ("a single tool... used to perform many different tasks") rather than
## TrenchBroom's separate V/E/F tools, which cost three keys we do not have.
##
## The snap radius is a fraction of the grid step, so what you get scales with how
## precisely you are working: on a coarse grid corners grab from further away.
## `add` extends the selection instead of replacing it, and the newly picked brush
## becomes ACTIVE either way — last clicked wins, which is the only rule anyone has to
## remember for "which one is the operand".
func _pick_element(add: bool = false, at: Variant = null) -> void:
# THE BOT EDITOR PICKS BOTS ONLY: no brushes, no other objects.
if in_bot_editor():
var bp := _pick_object(at, "bot")
if not bp.is_empty():
select_object(String(bp["dot"]), add)
return
var hit := _crosshair_hit(at)
# OBJECTS FIRST, NEARER WINS. Lights and markers have no collider, so the physics
# ray cannot see them; SceneObjects.pick tests each object's bounds instead. A
# model's own collision IS hit by the ray, at (about) the same distance its bounds
# are entered — which is why the object wins a tie.
var opick := _pick_object(at)
# A CURVE'S WIRE (§16.7 Q4), by the same rule: a curve lies ON a surface (its points
# lifted ~0.1 m), so the ray nearly always hits the brush just behind it — nearer wins,
# never "only where nothing else is hit".
var cpick := pick_curve(at)
if not cpick.is_empty():
var hd0: float = INF
if not hit.is_empty():
hd0 = (hit["position"] as Vector3).distance_to(_crosshair_ray(at)["from"] as Vector3)
if float(cpick["dist"]) <= hd0 + 0.05 and (opick.is_empty() or float(cpick["dist"]) < float(opick["dist"])):
select_curve(String(cpick["dot"]), add) # Shift adds (§17.10)
return
if not opick.is_empty():
var hd: float = INF
if not hit.is_empty():
var ray := _crosshair_ray(at)
hd = (hit["position"] as Vector3).distance_to(ray["from"] as Vector3)
if float(opick["dist"]) <= hd + 0.05:
select_object(String(opick["dot"]), add) # Shift-click toggles, like brushes
return
if hit.is_empty():
print("[MapDCLevel] pick: nothing under the crosshair")
return
if not level.has_method("pick_face"):
push_warning("[MapDCLevel] this dc-terrain build predates pick_face — rebuild the extension")
return
var brush := _brush_from_hit(hit)
if brush < 0:
print("[MapDCLevel] pick: hit %s, which is not level geometry" % str(hit.get("collider")))
return
# A PARAMETRIC SHAPE'S PIECE: brush mode selects the SHAPE. Vertex / edge / face mode
# select the PART like on any brush (#109, user 2026-10-13: "select, prompt on edit";
# every new box is a shape, so element selection looked broken). The pieces are still
# read-only (docs/parametric-shapes-design.md §7): an EDIT of one asks first
# (guard_shape_part: Make Real, then go on / Cancel).
# A PLACED ASSET's piece (#120 L4): brush mode selects the PLACEMENT, as for a shape.
var pa := placement_of_brush(brush)
if pa != "" and _sel_mode == "brush":
select_asset(pa)
_sel_point = hit["position"]
_sel_kind = "brush"
_update_face_highlight()
_update_element_highlight()
_update_op_hud()
return
var sd := shape_of_brush(brush)
if sd != "" and _sel_mode == "brush":
select_shape(sd)
_sel_point = hit["position"]
_sel_kind = "brush"
_update_face_highlight()
_update_element_highlight()
_update_op_hud()
return
# BRUSH MODE selects the solid, not a piece of it: every live plane, so the
# highlight shows the whole thing, with the kind naming what you actually chose.
# G then moves it bodily instead of dragging one of its planes.
if _sel_mode == "brush":
_note_selected(brush, add)
_sel_faces.clear()
_sel_edges.clear()
_sel_keys = level.get_brush(brush).get("plane_keys", PackedStringArray())
_sel_point = hit["position"]
_sel_kind = "brush"
_update_face_highlight()
_update_element_highlight()
_update_op_hud()
print("[MapDCLevel] selected brush %d (%d planes)" % [brush, _sel_keys.size()])
return
var face: int = level.pick_face(brush, hit["position"])
if face < 0:
print("[MapDCLevel] pick: brush %d has no face at %s" % [brush, str(hit["position"])])
return
var p: Vector3 = hit["position"]
var pts: PackedVector3Array = level.get_face_winding(brush, face)
var target := p
var kind := _sel_mode
# THE MODE DECIDES, and there is no radius any more. Proximity picking could not
# be ASKED for a face while you stood near its corner; in vertex mode you get the
# nearest vertex however far it is, because that is what you said you wanted.
if _sel_mode == "vertex":
var best := INF
for v: Vector3 in pts:
var dv: float = v.distance_to(p)
if dv < best:
best = dv
target = v
elif _sel_mode == "edge":
var beste := INF
for i in range(pts.size()):
var a: Vector3 = pts[i]
var b: Vector3 = pts[(i + 1) % pts.size()]
var ab := b - a
var t: float = clampf((p - a).dot(ab) / maxf(ab.length_squared(), 1e-9), 0.0, 1.0)
var q: Vector3 = a + ab * t
var dq: float = q.distance_to(p)
if dq < beste:
beste = dq
target = q
# THE ENDPOINTS, captured HERE. _update_element_highlight draws the
# edge from these two, and the mode rewrite dropped the capture — so
# edge mode drew a line from the origin to the origin, which is to say
# nothing at all. They are computed in this loop anyway; recovering
# them afterwards would mean intersecting the two incident planes and
# re-clipping against the brush to find where the edge stops.
_sel_edge_a = a
_sel_edge_b = b
if pts.size() < 3:
# No winding to resolve against — fall back to the face itself rather than
# selecting a point that came from nowhere.
target = p
kind = "face"
# The incident planes ARE the selection. eps is generous relative to the grab
# radius so a corner reliably collects all three of its planes.
var eps: float = maxf(_grid_step() * 0.05, 1e-4)
var idx: PackedInt32Array = _planes_through(brush, target, eps)
if idx.is_empty():
idx = PackedInt32Array([face])
target = p
kind = "face"
var keys: PackedStringArray = level.get_brush(brush).get("plane_keys", PackedStringArray())
_sel_keys = PackedStringArray()
for i in idx:
if i < keys.size():
_sel_keys.append(keys[i])
# The cross-brush FACE SET: Shift toggles this face in it, a plain click restarts
# it. Captured before _note_selected, whose `add` toggle may drop the brush from
# _sel_brushes when a second face of an already-selected brush is clicked.
# The cross-brush EDGE SET (§14.4), the same way: Shift toggles, a plain click restarts.
if kind == "edge":
var ek := _edge_key(brush, _sel_edge_a, _sel_edge_b)
var ei := _sel_edges.find(ek)
if ek.is_empty():
pass
elif not add:
_sel_edges = [ek]
elif ei >= 0:
_sel_edges.remove_at(ei)
else:
_sel_edges.append(ek)
else:
_sel_edges.clear()
if kind == "face" and face < keys.size():
var fk := {"b": String(level.get_brush_key(brush)), "p": keys[face]}
var fi := _sel_faces.find(fk)
if not add:
_sel_faces = [fk]
elif fi >= 0:
_sel_faces.remove_at(fi)
else:
_sel_faces.append(fk)
else:
_sel_faces.clear()
if (kind == "face" or kind == "edge") and add and _sel_brushes.has(brush):
_sel_brush = brush # another face / edge of a selected brush: keep it, make it active
else:
_note_selected(brush, add)
_sel_point = target
_sel_kind = kind
_probe("pick: wash")
_update_face_highlight()
_probe("pick: element")
_update_element_highlight()
_probe("pick: handles")
_sync_handle_visibility()
_probe("pick: hud")
_update_op_hud()
_probe("pick: done")
print("[MapDCLevel] selected %s on brush %d — %d incident plane(s) %s at %s"
% [kind, brush, _sel_keys.size(), str(_sel_keys), str(target)])
## Move the selected element by one grid step.
##
## ONE RULE FOR ALL THREE: shift the target point along the average of the
## incident normals, then set d_i = n_i . P' for every incident plane. For a face
## that average IS the face normal, so this reduces exactly to the old nudge
## (d = n.(P + n*step) = d + step) — vertex and edge are not a special case bolted
## on, they are the same operation at k=0 and k=1.
##
## Every plane move is journalled separately, carrying the plane it replaced,
## because set_face_plane has no other inverse.
func _nudge_element(delta: float) -> void:
if _sel_kind == "brush":
# Every plane pushed outward is a SCALE, not a move, and nobody pressing `]`
# on a selected brush means that. G moves it.
print("[MapDCLevel] brush mode: press G to move the brush")
return
# A shape's part: Make Real first (or Cancel), then this nudge on the same part.
if not guard_shape_part("nudge", _nudge_element.bind(delta)):
return
var dir := element_out_dir()
if dir == Vector3.ZERO:
return # element_out_dir has already said why
# Proportional (O): the nearby corners, collected BEFORE the part moves, follow after.
var near: Array = prop_corners(element_corners(), _sel_brush)
var from := _sel_point
_ensure_persistence()
_persist.begin_gesture("Nudge") # the part and its followers: one Ctrl+Z
if apply_element_move(_sel_point + dir * delta) > 0 and not near.is_empty():
prop_move_corners(near, _sel_point - from)
_persist.end_gesture()
## The selected element's incident planes, as CURRENT indices. Empty if there is no
## selection or every plane of it has gone — both of which are said out loud, because
## a nudge that silently does nothing is indistinguishable from a broken key.
##
## Extracted so the drag operator and the `[` / `]` nudge resolve the selection ONE
## way. Two implementations of "which planes am I moving" is the shape of bug this
## codebase keeps paying for.
func element_planes() -> PackedInt32Array:
if _sel_brush < 0 or _sel_keys.is_empty():
print("[MapDCLevel] nothing selected — aim at a face, edge or corner and press B")
return PackedInt32Array()
if level.get_brush(_sel_brush).is_empty():
print("[MapDCLevel] selected brush %d is gone" % _sel_brush)
_clear_face_selection()
return PackedInt32Array()
# Resolve dots to current indices; a plane that vanished drops out.
var idx := PackedInt32Array()
for k: String in _sel_keys:
var i: int = level.face_by_key(_sel_brush, k)
if i >= 0:
idx.append(i)
if idx.is_empty():
print("[MapDCLevel] every selected plane is gone from brush %d" % _sel_brush)
_clear_face_selection()
return idx
return idx
## Which way "out" is for the selection: the average of its incident normals. For a
## face that IS the face normal; for a corner it is the diagonal you would expect to
## drag it along. ZERO when opposed normals cancel, which is a real shape (a slab
## selected on both sides) and not an error.
func element_out_dir() -> Vector3:
var idx: PackedInt32Array = element_planes()
if idx.is_empty():
return Vector3.ZERO
var pl: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
var dir := Vector3.ZERO
for i in idx:
dir += Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
if dir.length() < 1e-6:
print("[MapDCLevel] those planes have no common outward direction")
return Vector3.ZERO
return dir.normalized()
## CSG — the ACTIVE brush against the others in the selection.
##
## Blender's model, which is what the selection set was built for: the last brush you
## clicked is the one that survives and is operated ON, and everything else selected is
## an operand. TrenchBroom has no active concept — its csgSubtract takes the selection
## as CUTTERS and auto-selects everything touching them as targets
## (Map_Geometry.cpp:685-700). That is fewer clicks and alarming here, where a cutter
## grazing a floor and three walls would carve all four unasked.
##
## THE OPERANDS ARE CONSUMED BY DEFAULT — `csg_discard`, Alt+H toggles it. A cutter
## is usually a tool shape, not level geometry, and leaving it behind means deleting it
## by hand every time (TrenchBroom deletes its subtrahends for the same reason). But
## not always — one cutter can open a row of doorways (user 2026-10-05: "there are
## times when you do and do not want to discard"). KEPT, the cutters come back as the
## selection: still solid, standing in the hole they cut, so G moves one on to the next
## cut. Merge consumes its operands either way — it is one hull of all of them.
## Undo brings everything back, because every step here is a record and undo is a
## re-fold.
func _csg(op: String) -> void:
if _sel_brush < 0 or _sel_brushes.size() < 2:
print("[csg] %s needs at least two brushes selected — the ACTIVE one is the "
% op + "target, the rest are operands")
return
var operands: Array[int] = []
for b: int in _sel_brushes:
if b != _sel_brush:
operands.append(b)
if op != "merge" and _live_cut(op, operands):
return
if op == "slice":
# LIVE ONLY (§9c): there is no destructive slice, and doing a subtract instead
# would be doing something else without saying so.
print("[csg] Slice needs a SHAPE as the cutter, and the target in a shape or group")
return
match op:
"merge":
# One call: the hull of every selected brush's vertices, active first so
# it is the survivor. NOT a union — it fills the space between separated
# brushes — which is why the binding says "convex merge" and always will.
var ids := PackedInt32Array([_sel_brush])
var keys: Array = [level.get_brush_key(_sel_brush)]
for b: int in operands:
ids.append(b)
keys.append(level.get_brush_key(b))
var dot: String = _persist.reserve_dot()
if not level.merge_brushes(ids, dot):
print("[csg] merge refused — that hull encloses nothing")
return
_persist.record_at(dot, "brush.merge", {"brush_keys": keys})
print("[csg] convex merge of %d brushes (a HULL, not a union)" % ids.size())
"intersect":
# Each operand narrows the active brush further. One record per operand,
# each carrying that operand's PLANE SET — never its id, which means
# something different on every peer.
for b: int in operands:
var pl: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
var dot2: String = _persist.reserve_dot()
if not level.intersect_brush(_sel_brush, pl, dot2):
print("[csg] intersect refused — those brushes do not overlap")
return
var flat: Array = []
for f: float in pl:
flat.append(f)
_persist.record_at(dot2, "brush.intersect", {
"brush_key": level.get_brush_key(_sel_brush), "planes": flat,
})
if csg_discard:
_remove_brushes(operands)
print("[csg] intersected with %d brush(es)" % operands.size())
"subtract":
# THE PIECES ARE THEMSELVES TARGETS. Subtracting the second cutter has to
# happen against everything the first one left, so this accumulates — and
# that is why three cutters against one wall can leave nine brushes rather
# than four. TrenchBroom folds subtrahends over minuends the same way and
# multiplies the same. Said out loud below, because discovering it by
# watching a wall become nine brushes is worse than being told.
var targets: Array[int] = [_sel_brush]
for b: int in operands:
var cut: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
var flat2: Array = []
for f2: float in cut:
flat2.append(f2)
var next: Array[int] = []
for t: int in targets:
var dot3: String = _persist.reserve_dot()
var tkey: String = level.get_brush_key(t)
var n: int = level.subtract_brush(t, cut, dot3)
if n < 0:
next.append(t) # this cutter missed this piece: keep it
continue
_persist.record_at(dot3, "brush.subtract", {
"brush_key": tkey, "planes": flat2,
})
for k in range(n):
var pid: int = level.brush_by_key(dot3 if k == 0 else "%s:%d" % [dot3, k])
if k == 0:
pid = t # the first piece keeps the target's id
if pid > 0:
next.append(pid)
targets = next
if csg_discard:
_remove_brushes(operands)
print("[csg] subtract left %d piece(s) from %d cutter(s)%s"
% [targets.size(), operands.size(), "" if csg_discard else " (cutters kept)"])
level.rebuild()
_refresh_brush_view()
# THE SELECTION IS STALE. Brushes were consumed and faces re-derived, so the dots
# it holds may name geometry that is gone — and a selection quietly pointing at
# the wrong brush is how the NEXT csg op eats something nobody chose.
_clear_face_selection()
# Kept cutters are untouched by the op, so their ids are still good: hand them
# back as the selection, ready to move on to the next cut. select_brushes, not
# _note_selected: it sets the "brush" kind and grab point G needs.
if not csg_discard and op != "merge":
select_brushes(PackedInt32Array(operands))
## LIVE CUTS — live by default (user 2026-10-12; docs/cutting-and-operations.md §9a).
## The active brush's GROUP (a shape's dot, an operator group) is the target, and every
## operand must be a piece of a parametric shape: that shape becomes the cutter. One
## op.create per cutter shape, one gesture. Moving or editing either re-cuts; Make Real
## on the target bakes it. False (and nothing written) when the selection is not that —
## _csg then cuts destructively, as it always has.
func _live_cut(op: String, operands: Array[int]) -> bool:
if level == null or not level.has_method("get_cutter_brush_ids"):
return false # a DCLevel build without brush.cut
var target := String(level.get_brush_group(_sel_brush))
if target == "" or operands.is_empty():
return false
var cutters := {}
for b: int in operands:
var d := shape_of_brush(b)
if d == "" or d == target:
return false
cutters[d] = true
_ensure_persistence()
_persist.begin_gesture("Live %s" % op)
var n := 0
for d: String in cutters:
if _persist.record("op.create", {"op": op, "cutter": d, "targets": [target]}) != "":
n += 1
_persist.end_gesture()
_persist.refold()
_refresh_brush_view()
_clear_face_selection()
var ids := PackedInt32Array()
for d: String in cutters:
ids.append_array(level.get_group_brush_ids(d))
select_brushes(ids)
print("[csg] LIVE %s: %d cutter shape(s) on %s — move or edit either and it re-cuts; Make Real on the target bakes it"
% [op, n, target])
return n > 0
## Make Real on a cut's TARGET bakes the cut in, so the ops cutting only it stop
## (op.apply) and a cutter no other live op uses goes with them (shape.remove) —
## Blender's Apply. Called inside the caller's gesture: one Ctrl+Z brings it all back.
func _retire_ops_on(target: String) -> void:
# EXISTING ops, off ones too: an op switched off in the Stack panel still belongs to
# the stack, and baking the target ends it either way.
var ops: Array = LevelPersistence.OpFold.existing_ops(_persist.records(false))
var retired := {}
for s: Dictionary in ops:
var tg: PackedStringArray = s["targets"]
if tg.size() == 1 and tg[0] == target:
_persist.record("op.apply", {"target": s["dot"]})
retired[s["dot"]] = true
var removed := {}
for s2: Dictionary in ops:
var c := String(s2["cutter"])
if not retired.has(s2["dot"]) or c == "" or removed.has(c):
continue
var still_used := false
for s3: Dictionary in ops:
if not retired.has(s3["dot"]) and String(s3["cutter"]) == c:
still_used = true
if not still_used:
_persist.record("shape.remove", {"target": c})
removed[c] = true
## ---- THE STACK PANEL's API (docs/cutting-and-operations.md §9b) ----
##
## SUBJECT = the selected shape, else the active brush's group ("" = nothing stackable).
func stack_subject() -> String:
if _sel_shape != "":
return _sel_shape
if _sel_brush >= 0 and level != null:
var d := shape_of_brush(_sel_brush)
if d != "":
return d
return String(level.get_brush_group(_sel_brush))
return ""
## The subject's ops, in stack order: [{dot, kind, on, cutter, depth}] (depth: inset / outset).
func op_stack(subject: String) -> Array:
_ensure_persistence()
var out: Array = []
for s: Dictionary in LevelPersistence.OpFold.stack_of(_persist.records(false), subject):
out.append({"dot": s["dot"], "kind": s["kind"], "on": bool(s.get("on", true)), "cutter": s["cutter"],
"depth": float(s.get("depth", 0.25))})
return out
## The ops cutting WITH `cutter`: [{dot, kind, on, target}] (one per target).
func cuts_by(cutter: String) -> Array:
_ensure_persistence()
var out: Array = []
for s: Dictionary in LevelPersistence.OpFold.cuts_by(_persist.records(false), cutter):
for t: String in (s["targets"] as PackedStringArray):
out.append({"dot": s["dot"], "kind": s["kind"], "on": bool(s.get("on", true)), "target": t})
return out
## One op.set (the eye: {on}; the kind: {op}) — one gesture, one Ctrl+Z.
func set_op(dot: String, fields: Dictionary) -> bool:
_ensure_persistence()
var p := fields.duplicate()
p["target"] = dot
var label := "Cut on" if bool(fields.get("on", true)) else "Cut off"
if fields.has("op"):
label = "Cut: %s" % String(fields["op"])
_persist.begin_gesture(label)
var ok: bool = _persist.record("op.set", p) != ""
_persist.end_gesture()
if ok:
_persist.refold()
_refresh_brush_view()
return ok
## ✕ — op.remove. Its cutter becomes a solid shape again (it cuts nothing now); Ctrl+Z
## brings the cut back.
func remove_op(dot: String) -> bool:
_ensure_persistence()
_persist.begin_gesture("Remove cut")
var ok: bool = _persist.record("op.remove", {"target": dot}) != ""
_persist.end_gesture()
if ok:
_persist.refold()
_refresh_brush_view()
return ok
## Tombstone the operands, one record each, so undo brings them back.
func _remove_brushes(ids: Array[int]) -> void:
for b: int in ids:
var bkey: String = level.get_brush_key(b)
if level.remove_brush(b):
_persist.record("brush.remove", {"brush_key": bkey})
## THE CORNERS a vertex or edge drag actually moves, or empty for a face.
##
## Empty is the signal to use the exact PLANE path instead: a face IS one plane, so
## moving it is one number and there is nothing to rebuild. A vertex and an edge have
## no such luxury — they are the meet of three planes and of two, so moving them by
## moving planes drags every face those planes carry, which is what "vertex mode
## moves the whole brush" has meant every time it was reported.
func element_drag_points() -> PackedVector3Array:
match _sel_kind:
"vertex":
return PackedVector3Array([_sel_point])
"edge":
return PackedVector3Array([_sel_edge_a, _sel_edge_b])
return PackedVector3Array()
## ---- LOCAL AXES (#109, user 2026-10-13: "double tap of an axis to switch to normal
## aligned — I rotate a cube and K still cuts on the global axis") -------------------
##
## Blender's X then X again: the second press is the thing's OWN axis. A parametric
## shape has a frame, and that is its local basis. A plain brush has only planes, so its
## local X / Y / Z are the face normals most aligned with the world's X / Y / Z,
## orthonormalised in that order — a rotated cube's own face normals, and the world
## basis for anything axis-aligned. LEVEL space.
func brush_local_basis(b: int) -> Basis:
var sd := shape_of_brush(b)
if sd != "":
var st: Dictionary = _persist.shape_state(sd)
if st.get("frame") is Transform3D:
return (st["frame"] as Transform3D).basis.orthonormalized()
var pl: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
var picked: Array[Vector3] = []
for e: Vector3 in [Vector3.RIGHT, Vector3.UP, Vector3.BACK]:
var best := Vector3.ZERO
for i in pl.size() / 4:
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).normalized()
if absf(n.dot(e)) > absf(best.dot(e)):
best = n
picked.append(best if best.dot(e) >= 0.0 else -best)
var x: Vector3 = picked[0]
var y: Vector3 = (picked[1] - x * picked[1].dot(x))
if x.length() < 1e-6 or y.length() < 1e-6:
return Basis.IDENTITY
y = y.normalized()
return Basis(x.normalized(), y, x.normalized().cross(y))
## ---- ELEMENT ROTATE (#109, user 2026-10-13: "rotate with 3d cursor as pivot doesn't
## work for edge / vertex, it rotates the entire brush") --------------------------------
##
## RotateOperator only ever turned whole brushes. The element rule is element MOVE's:
## A FACE TURNS ITS PLANE (one face.set, rigid about the pivot; neighbours stretch, as
## for extrude), A CORNER MOVES ITSELF (brush.reshape: re-hull with the named corners
## moved). An edge is two corners going to two different places, and move_brush_points
## takes one delta — so one reshape per endpoint, both applied locally BEFORE either is
## journalled; if the store refuses either, refold() puts the level back exactly as the
## journal says. The operator stack wraps the confirm in one gesture: one Ctrl+Z.
## Where an element turns about: the 3D cursor in cursor mode, else the element's own
## centre — a vertex, an edge's midpoint, a face's polygon centroid. LEVEL space.
func element_pivot() -> Vector3:
if pivot_mode == "cursor" and _cursor3d != null:
return (level as Node3D).global_transform.affine_inverse() * _cursor3d.global_position
match _sel_kind:
"edge":
return (_sel_edge_a + _sel_edge_b) * 0.5
"face":
var idx: PackedInt32Array = element_planes()
if not idx.is_empty():
var w: PackedVector3Array = level.get_face_winding(_sel_brush, idx[0])
if not w.is_empty():
var c := Vector3.ZERO
for p: Vector3 in w:
c += p
return c / float(w.size())
return _sel_point
func apply_element_rotate(axis: Vector3, deg: float, pivot: Vector3) -> int:
var idx: PackedInt32Array = element_planes()
if idx.is_empty() or is_zero_approx(fmod(deg, 360.0)):
return 0
var r := Basis(axis.normalized(), deg_to_rad(deg))
if _sel_kind == "face":
var pl: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
var i: int = idx[0]
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
var d: float = pl[i * 4 + 3]
var n2: Vector3 = r * n
var q2: Vector3 = r * (n * d - pivot) + pivot # a point on the plane, turned
if not level.set_face_plane(_sel_brush, i, n2, n2.dot(q2)):
print("[MapDCLevel] refused: turning that face would leave brush %d enclosing nothing" % _sel_brush)
return 0
_sel_point = r * (_sel_point - pivot) + pivot
level.rebuild()
_update_face_highlight()
_update_element_highlight()
var keys: PackedStringArray = level.get_brush(_sel_brush).get("plane_keys", PackedStringArray())
if i < keys.size():
_persist.record("face.set", {"brush_key": level.get_brush_key(_sel_brush), "face_key": keys[i],
"n": [n2.x, n2.y, n2.z], "d": n2.dot(q2)})
print("[MapDCLevel] face turned %+.1f° about %s" % [deg, str(pivot)])
return 1
var corners: PackedVector3Array = element_drag_points()
if corners.is_empty():
return 0
var bkey: String = level.get_brush_key(_sel_brush)
var moves: Array = [] # [dot, from, delta] per corner
for c: Vector3 in corners:
var delta: Vector3 = r * (c - pivot) + pivot - c
var dot: String = _persist.reserve_dot()
if not level.move_brush_points(_sel_brush, PackedVector3Array([c]), delta, dot):
print("[MapDCLevel] refused: turning that %s would not leave a solid — nothing changed" % _sel_kind)
_persist.refold() # undo the corners already moved
return 0
moves.append([dot, c, delta])
level.rebuild()
_refresh_brush_view()
for m: Array in moves:
var c2: Vector3 = m[1]
var dl: Vector3 = m[2]
_persist.record_at(String(m[0]), "brush.reshape", {"brush_key": bkey,
"points": [[c2.x, c2.y, c2.z]], "delta": [dl.x, dl.y, dl.z]})
print("[MapDCLevel] %s turned %+.1f° about %s (%d corner(s))" % [_sel_kind, deg, str(pivot), moves.size()])
# The plane set was re-derived (element move's rule): the dots may name faces
# that no longer exist, so the selection is dropped rather than left pointing wrong.
_clear_face_selection()
return moves.size()
## Move every incident plane so it passes through `target`, keeping its normal.
## Returns how many moved, or 0 if the store refused and nothing changed.
##
## THIS IS THE WHOLE OPERATION, for the keyboard nudge and the drag operator alike.
func apply_element_move(target: Vector3) -> int:
var idx: PackedInt32Array = element_planes()
if idx.is_empty():
return 0
var pl: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
# Apply as a batch, and ROLL BACK if any plane refuses: a partly-moved vertex
# is not the shape the player asked for, and the empty-brush guard rejecting
# one of three planes would otherwise leave a corner torn open.
var applied := []
var failed := false
for i in idx:
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
var prev_d: float = pl[i * 4 + 3]
if level.set_face_plane(_sel_brush, i, n, n.dot(target)):
applied.append([i, n, prev_d])
else:
failed = true
break
if failed:
for e in applied:
level.set_face_plane(_sel_brush, e[0], e[1], e[2])
print("[MapDCLevel] refused: that would leave brush %d enclosing nothing" % _sel_brush)
return 0
_sel_point = target
level.rebuild()
_update_face_highlight()
_update_element_highlight()
if not _persist.is_applying():
var keys2: PackedStringArray = level.get_brush(_sel_brush).get("plane_keys", PackedStringArray())
var bkey: String = level.get_brush_key(_sel_brush)
for e in applied:
var n2: Vector3 = e[1]
if e[0] < keys2.size():
# NO `prev`. The record says where the plane IS; undo is the record
# going away and the fold recomputing from the ones that remain. A
# stored inverse is only correct for a linear history of one plane —
# two nudges and an undo of the first put it where nobody asked.
_persist.record("face.set", {
"brush_key": bkey,
"face_key": keys2[e[0]],
"n": [n2.x, n2.y, n2.z],
"d": n2.dot(target),
})
print("[MapDCLevel] %s moved %d plane(s) to %s %s"
% [_sel_kind, applied.size(), str(target), str(level.get_stats())])
return applied.size()
## Hide the placeholder once the level actually has geometry.
func drop_placeholder_floor() -> void:
if placeholder_floor != null:
placeholder_floor.queue_free()
placeholder_floor = null
print("[MapDCLevel] placeholder floor removed — level geometry is authoritative")
# ---- (C) two-point face-to-face fill ----------------------------------------
#
# Cepero's Elastic Fill, which is the only interaction in either prior source
# designed for AIMING rather than adapted to it (hyper-games #21):
#
# "You need to point to two opposing faces... You point to the start place,
# hold a key down, and then point to the end place. You see some sort of
# elastic box showing you where the voxels will appear. Once you release the
# key, the program will fill the space you have highlighted."
#
# FACE to FACE, not point to point — which is exactly why it lands on this API
# with no adaptation and no virtual cursor: pick_face already turns an aim into a
# face, and get_face_winding already gives that face's polygon to draw.
#
# THE SOLID IS BUILT BY CONSTRUCTION, NOT BY HULLING POINTS. Take the start
# brush's plane set, FLIP the start face's plane so the new solid lies on the far
# side of it, and append the target face's plane flipped as the far cap. An
# intersection of half-spaces is convex by definition, so the result is a valid
# brush with no vertex solving and no plane re-fitting — the property the whole
# level-mesh architecture rests on.
#
# JOURNALLED now. It could not be before: brush ids came from a local
# `next_brush_id_++`, so two peers holding the same map had their counters in the
# same place and each allocated THE SAME id for DIFFERENT geometry — replicating
# that would have been known-unsound, which is why this shipped local-only. Brushes
# are now named by the dot of the op that created them, exactly as faces already
# were, so a fill is a `brush.create` record like any other.
## A mis-aimed second point should not fill a room.
const FILL_MAX_SPAN := 64.0
## Last aimed brush the frame probe reported, so it prints on change rather than
## sixty times a second.
var _probe_last_aim: int = -2
var _fill_from_brush: int = -1
var _fill_from_key: String = ""
var _fill_ghost: MeshInstance3D = null
func _process(_delta: float) -> void:
# UV keys exist only while something is selected (keymap `face_sel`).
var want_face_sel := not _sel_brushes.is_empty()
if want_face_sel != _face_sel_on:
_face_sel_on = want_face_sel
if want_face_sel:
Keymap.push_context("face_sel", self)
else:
Keymap.pop_context("face_sel", self)
# One int compare unless a face texture (or the resolution) actually changed.
if _packs != null:
_packs.sync()
_update_object_outline()
if _fill_from_brush >= 0:
_update_fill_ghost()
# Outline whatever is under the crosshair. One ray, and set_aimed does nothing
# unless the target actually changed, so this is a comparison per frame in the
# common case of standing still.
if get_viewport() != null:
_update_pointer_wrap_at(get_viewport().get_mouse_position())
if _ops != null and is_instance_valid(_ops) and _ops.active != null:
_ops.update_preview()
# A tool's handle dots over its preview (§17.9).
var hpts: PackedVector3Array = (_ops.active.modal() as ModalControls).handle_points()
if not hpts.is_empty():
_ops.active.draw_points(hpts)
_update_op_hud()
# Face handles only with no tool up (the tool draws its own preview).
if _handle_view != null and is_instance_valid(_handle_view):
_handle_view.visible = GameMode.is_building() and (_ops == null or not is_instance_valid(_ops) or _ops.active == null)
# ONE crosshair ray per frame, shared by the grid and the brush-view aim. None while
# the free pointer is over an editor area (#124): hover lit up what was BEHIND a panel.
# The VIRTUAL pointer, as _crosshair_ray reads it (mouse + the wrap offset).
var over_area: bool = GameMode.wants_pointer() and BuildDock != null \
and BuildDock.covers(_pointer_pos(get_viewport().get_mouse_position()) + _wrap_offset)
var frame_hit: Dictionary = _crosshair_hit() if GameMode.is_building() and not over_area else {}
_update_editor_grid(frame_hit)
if _brush_view != null and is_instance_valid(_brush_view) and _brush_view.visible:
# Per FRAME, and the only editor path that runs without any key at all — so a
# crash "on C" could equally be this, one frame later. Probed only when the
# aimed brush CHANGES, or it would be one line per frame forever.
var aim_now: int = _brush_from_hit(frame_hit)
if aim_now != _probe_last_aim:
_probe_last_aim = aim_now
_probe("frame: set_aimed %d" % aim_now)
_brush_view.set_aimed(aim_now)
func _fill_begin() -> void:
var hit := _crosshair_hit()
if hit.is_empty():
print("[MapDCLevel] fill: nothing under the crosshair")
return
var brush := _brush_from_hit(hit)
if brush < 0:
print("[MapDCLevel] fill: that is not level geometry")
return
var face: int = level.pick_face(brush, hit["position"])
if face < 0:
return
var keys: PackedStringArray = level.get_brush(brush).get("plane_keys", PackedStringArray())
if face >= keys.size():
return
# Held by DOT, not index: the start face must survive whatever happens between
# press and release.
_fill_from_brush = brush
_fill_from_key = keys[face]
print("[MapDCLevel] fill: from brush %d face %d (%s) — aim at the opposing face and release"
% [brush, face, _fill_from_key])
func _fill_commit() -> void:
var from_brush := _fill_from_brush
var from_key := _fill_from_key
_fill_from_brush = -1
_fill_from_key = ""
_clear_fill_ghost()
if from_brush < 0:
return
var hit := _crosshair_hit()
if hit.is_empty():
print("[MapDCLevel] fill: released on nothing — cancelled")
return
var to_brush := _brush_from_hit(hit)
if to_brush < 0:
print("[MapDCLevel] fill: released on non-level geometry — cancelled")
return
var to_face: int = level.pick_face(to_brush, hit["position"])
var from_face: int = level.face_by_key(from_brush, from_key)
if to_face < 0 or from_face < 0:
print("[MapDCLevel] fill: a face went away between press and release — cancelled")
return
var fb: Dictionary = level.get_brush(from_brush)
var tb: Dictionary = level.get_brush(to_brush)
var fp: PackedFloat32Array = fb.get("planes", PackedFloat32Array())
var fproj: PackedInt32Array = fb.get("proj", PackedInt32Array())
var tp: PackedFloat32Array = tb.get("planes", PackedFloat32Array())
var sn := Vector3(fp[from_face * 4], fp[from_face * 4 + 1], fp[from_face * 4 + 2])
var sd: float = fp[from_face * 4 + 3]
var tn := Vector3(tp[to_face * 4], tp[to_face * 4 + 1], tp[to_face * 4 + 2])
var td: float = tp[to_face * 4 + 3]
# OPPOSING is the precondition Cepero states, and it is geometric, not
# stylistic: if the two outward normals do not face each other there is no
# bounded region between them and the "fill" is an unbounded slab.
var facing: float = sn.dot(tn)
if facing > -0.1:
print("[MapDCLevel] fill: those faces do not oppose each other (n·n = %.2f)" % facing)
return
var span: float = absf(sd + td) # distance between the two planes along sn
if span > FILL_MAX_SPAN:
print("[MapDCLevel] fill: %.1f m span exceeds the %.1f m limit" % [span, FILL_MAX_SPAN])
return
# Start brush's walls, its start face flipped, plus the target plane as a cap.
var planes := PackedFloat32Array()
var proj := PackedInt32Array()
for i in range(fp.size() / 4):
var n := Vector3(fp[i * 4], fp[i * 4 + 1], fp[i * 4 + 2])
var d: float = fp[i * 4 + 3]
if i == from_face:
n = -n
d = -d
planes.append_array(PackedFloat32Array([n.x, n.y, n.z, d]))
proj.append(fproj[i] if i < fproj.size() else 0)
planes.append_array(PackedFloat32Array([-tn.x, -tn.y, -tn.z, -td]))
proj.append(0)
# TWO-PHASE, because a create needs its durable name BEFORE it can be applied —
# the name is what the brush is called — while the store may still refuse the
# plane set. Reserve the dot, try it, and only write the record if it took: a
# record for a brush that does not exist would be replayed by every peer forever.
var dot: String = _persist.reserve_dot()
# Textured at birth like the box (#94 P1 §6): the current texture on every plane.
var face_tex: Array = []
var cur_tex := current_face_tex()
for _p in planes.size() / 4:
face_tex.append(cur_tex.duplicate())
var nid: int = level.add_brush(planes, int(fb.get("material_id", 0)), proj,
int(fb.get("level_id", 0)), false, dot, false, "", face_tex)
if nid <= 0:
# add_brush now validates emptiness itself, the way set_face_plane and
# add_face always did — so an impossible span is refused at creation instead
# of being created, rebuilt, counted and tombstoned after the fact.
print("[MapDCLevel] fill: refused — that plane set encloses nothing")
return
level.rebuild()
var live: int = int(level.get_brush(nid).get("live_faces", 0))
if live < 4:
# Encloses SOMETHING but not a solid worth having. Tombstone locally and
# write no record.
level.remove_brush(nid)
level.rebuild()
print("[MapDCLevel] fill: refused — only %d live faces" % live)
return
# JOURNALLED, which it could not be before: brush ids came from a local counter,
# so two peers creating a brush allocated the same id for different geometry and
# replicating that was known-unsound. The dot fixes exactly that.
var plane_list: Array = []
for f: float in planes:
plane_list.append(f)
var proj_list: Array = []
for p: int in proj:
proj_list.append(p)
_persist.record_at(dot, "brush.create", {
"planes": plane_list,
"proj": proj_list,
"material_id": int(fb.get("material_id", 0)),
"level_id": int(fb.get("level_id", 0)),
"sky": false,
"face_tex": face_tex,
})
print("[MapDCLevel] fill: brush %s (local %d), %d live faces, %.2f m span %s"
% [dot, nid, live, span, str(level.get_stats())])
## Both faces, drawn while the key is held. Not the resulting solid: showing that
## would mean building it every frame, and speculative mutation is exactly what
## poisons `generation` for anything downstream that compares it.
func _update_fill_ghost() -> void:
if _fill_ghost == null or not is_instance_valid(_fill_ghost):
_fill_ghost = MeshInstance3D.new()
_fill_ghost.name = "FillGhost"
_fill_ghost.mesh = ImmediateMesh.new()
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.albedo_color = Color(1.0, 0.7, 0.2, 0.35)
m.cull_mode = BaseMaterial3D.CULL_DISABLED
m.no_depth_test = true
_fill_ghost.material_override = m
_fill_ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
level.add_child(_fill_ghost)
var im: ImmediateMesh = _fill_ghost.mesh
im.clear_surfaces()
var from_face: int = level.face_by_key(_fill_from_brush, _fill_from_key)
if from_face < 0:
return
var polys: Array[PackedVector3Array] = []
polys.append(level.get_face_winding(_fill_from_brush, from_face))
var hit := _crosshair_hit()
if not hit.is_empty():
var tb := _brush_from_hit(hit)
if tb >= 0:
var tf: int = level.pick_face(tb, hit["position"])
if tf >= 0:
polys.append(level.get_face_winding(tb, tf))
im.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
for pts: PackedVector3Array in polys:
# Convex by construction — a brush face is an intersection of half-spaces —
# so a fan is exact.
for i in range(1, pts.size() - 1):
im.surface_add_vertex(pts[0])
im.surface_add_vertex(pts[i])
im.surface_add_vertex(pts[i + 1])
im.surface_end()
func _clear_fill_ghost() -> void:
if _fill_ghost != null and is_instance_valid(_fill_ghost):
(_fill_ghost.mesh as ImmediateMesh).clear_surfaces()
## ---- NEW BRUSHES FROM SELECTED FACES (user 2026-10-05) ------------------------
##
## "select multiple faces of close brushes to create new brushes that we can use for
## csg". Face mode, Shift-click across brushes builds `_sel_faces`; then:
## Alt+J HULL one brush, the convex hull of every selected face's corners. Two
## facing walls give the block between them; faces of one brush a
## slab shaped like them. Same hull as convex merge (J), by
## DCLevel.hull_planes — one hull rule, not two.
## Alt+E EXTRUDE one brush PER face: the face pushed out one grid step.
## The sources are untouched. The new brushes come back SELECTED in brush mode, the
## shape CSG wants next (Shift-click a wall to make it the active target, then H).
## Each is a brush.create — the same record fill and the box write.
## Face windings of `_sel_faces`, resolved: [{brush, face, pts, n}].
func _selected_face_polys() -> Array:
var out: Array = []
# A removed brush is a TOMBSTONE that brush_by_key still returns: only LIVE brushes'
# faces count (selected_edges' rule, gate_edge_set E8; gate_face_set_live).
var live := {}
for id: int in (level.get_live_brush_ids() as PackedInt32Array):
live[id] = true
for f: Dictionary in _sel_faces:
var b: int = level.brush_by_key(String(f["b"]))
if b <= 0 or not live.has(b):
continue
var fi: int = level.face_by_key(b, String(f["p"]))
if fi < 0:
continue
var pts: PackedVector3Array = level.get_face_winding(b, fi)
if pts.size() < 3:
continue
var pl: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
var n := Vector3(pl[fi * 4], pl[fi * 4 + 1], pl[fi * 4 + 2]).normalized()
out.append({"brush": b, "face": fi, "pts": pts, "n": n})
return out
## Add one brush and journal it: add_brush under a reserved dot, and the brush.create
## record only if the store took it (fill's two-phase rule). -1 if refused.
func _create_brush_journalled(p: Dictionary) -> int:
var dot: String = _persist.reserve_dot()
var nid: int = level.add_brush(PackedFloat32Array(p["planes"]), int(p.get("material_id", 0)),
PackedInt32Array(p.get("proj", [])), int(p.get("level_id", 0)), bool(p.get("sky", false)),
dot, bool(p.get("clearance", false)), String(p.get("group", "")), p.get("face_tex", []))
if nid <= 0:
return -1
_persist.record_at(dot, "brush.create", p)
return nid
## A brush.create payload for a NEW brush bounded by `planes`, textured at birth with
## the current texture (like the box and fill), material/level from `like`.
func _new_brush_payload(planes: PackedFloat32Array, like: int) -> Dictionary:
var lb: Dictionary = level.get_brush(like)
var pl: Array = []
var proj: Array = []
var ft: Array = []
var cur := current_face_tex()
for f: float in planes:
pl.append(f)
for _i in planes.size() / 4:
proj.append(0)
ft.append(cur.duplicate())
return {"planes": pl, "proj": proj, "material_id": int(lb.get("material_id", 0)),
"level_id": int(lb.get("level_id", 0)), "sky": false, "face_tex": ft}
## Hull of the selected faces' corners as one new brush. Its id, or -1.
func faces_to_hull() -> int:
var polys := _selected_face_polys()
if polys.size() < 1:
print("[faces] hull: select faces first (face mode, Shift-click to add)")
return -1
var pts := PackedVector3Array()
for p: Dictionary in polys:
pts.append_array(p["pts"])
var planes: PackedFloat32Array = level.hull_planes(pts)
if planes.is_empty():
print("[faces] hull: those faces all lie in one plane — nothing to fill (Alt+E extrudes a face)")
return -1
_persist.begin_gesture("Brush from faces")
var nid := _create_brush_journalled(_new_brush_payload(planes, int(polys[0]["brush"])))
_persist.end_gesture()
if nid < 0:
print("[faces] hull: the store refused that hull")
return -1
_after_new_brushes(PackedInt32Array([nid]))
print("[faces] hull of %d face(s) -> brush %d (%d planes)" % [polys.size(), nid, planes.size() / 4])
return nid
## Each selected face pushed out `depth` (default one grid step) as its own brush.
func faces_extrude(depth: float = -1.0) -> PackedInt32Array:
var made := PackedInt32Array()
var polys := _selected_face_polys()
if polys.is_empty():
print("[faces] extrude: select faces first (face mode, Shift-click to add)")
return made
if depth <= 0.0:
depth = _grid_step()
_persist.begin_gesture("Extrude faces to brushes")
for p: Dictionary in polys:
var src: PackedVector3Array = p["pts"]
var pts := PackedVector3Array(src)
for v: Vector3 in src:
pts.append(v + (p["n"] as Vector3) * depth)
var planes: PackedFloat32Array = level.hull_planes(pts)
if planes.is_empty():
continue
var nid := _create_brush_journalled(_new_brush_payload(planes, int(p["brush"])))
if nid > 0:
made.append(nid)
_persist.end_gesture()
if made.is_empty():
print("[faces] extrude: nothing was made")
return made
_after_new_brushes(made)
print("[faces] extruded %d face(s) %.3f m -> %d brush(es)" % [polys.size(), depth, made.size()])
return made
## Copy one shape (its shape.create, so it stays parametric) and link the copy into the
## source's collections — inside the caller's gesture. Returns the copy's dot ("" if refused).
## `linked` (Alt+D): the copy SHARES the source's data (ShapeFold._link_data) — its record
## names the ROOT (a link of a link names the first shape), and still carries the data as
## a snapshot, so it stands on its own if the root's create is undone.
func _dup_shape(tree: Dictionary, src: String, linked: bool = false) -> String:
var s: Dictionary = _persist.shape_state(src)
if s.is_empty():
return ""
# The RESOLVED state holds frame/size DECODED (Transform3D / Vector3); the record
# wants the wire form the box operator writes.
var fr: Variant = s["frame"]
if fr is Transform3D:
fr = SceneObjects.xform_to(fr)
var sz: Variant = s["size"]
if sz is Vector3:
sz = [sz.x, sz.y, sz.z]
var payload := {"type": s["type"], "gv": s["gv"],
"params": s["params"], "frame": fr, "size": sz,
"material_id": s.get("material_id", 0), "level_id": s.get("level_id", 0),
"tex": s.get("tex", {}), "input": (s.get("input", {}) as Dictionary).duplicate(true)}
if linked:
var root := String(s.get("link", ""))
payload["link"] = root if root != "" and not _persist.shape_state(root).is_empty() else src
var sd: String = _persist.record("shape.create", payload)
if sd != "":
_link_copy(tree, "g:" + src, "g:" + sd)
return sd
## Link a copy into every collection its source is in (inside the caller's gesture).
func _link_copy(tree: Dictionary, src: String, copy: String) -> void:
for c: String in CollectionFold.collections_of(tree, src):
_persist.record("col.link", {"target": c, "members": [copy]})
## Rebuild and hand the new brushes over as the selection, in BRUSH mode.
func _after_new_brushes(ids: PackedInt32Array) -> void:
level.rebuild()
_refresh_brush_view()
_sel_faces.clear()
_sel_edges.clear()
_sel_mode = "brush"
select_brushes(ids)
_update_face_highlight()
_update_element_highlight()
## ---- DUPLICATE (Shift+G, user 2026-10-05: "a method like in blender to duplicate") --
##
## Blender's Shift+D: copy the selection in place and pick it up. Shift+D itself is a
## strafe while flying, so it is Shift+G — "grab a copy". Objects copy type, place and
## properties; a parametric shape copies its shape.create (it stays parametric);
## brushes copy their whole state (planes, textures, flags). One gesture, so one Ctrl+Z
## takes the copies back. Then Move starts on them.
##
## THE STRUCTURE COMES ALONG (#120 F1, outliner audit bug 2), as in Blender:
## - the copies of one brush object's brushes form ONE new object (a fresh group per
## source group); a loose brush's copy stays loose;
## - an object's copy keeps its parent — or, when the parent is copied too, hangs under
## the parent's copy;
## - every copy is linked into the collections its source is in.
##
## LINKED (Alt+D, Blender's Duplicate Linked): a shape's copy shares its DATA (generator,
## params, texture) with the source — edit either and both change; place, size, modifiers
## and cuts stay its own. A scene object already shares its asset, so it copies as with
## Shift+D. A plain brush has no data apart from its object: it is NOT copied (Make
## parametric first), and says so.
func _duplicate_one(linked: bool = false) -> bool:
_ensure_persistence()
var tree: Dictionary = _persist.collection_tree()
if _sel_curve != "":
var cd := duplicate_curve(_sel_curve)
print("[duplicate] curve -> %s" % cd if cd != "" else "[duplicate] the journal refused the curve copy")
return cd != ""
if not _sel_objects.is_empty():
var made: Array[String] = []
var copy_of := {} # source dot -> its copy
_persist.begin_gesture("Duplicate")
for dot: String in _sel_objects:
var n := object_node(dot) as Node3D
if n == null:
continue
var nd := create_object(String(n.get_meta("object_type", "")), n.global_transform)
if nd == "":
continue
var pr := object_props(dot)
if not pr.is_empty():
set_object_props(nd, pr)
made.append(nd)
copy_of[dot] = nd
for dot: String in copy_of:
var par := object_parent(dot)
if par != "":
parent_objects([copy_of[dot]], String(copy_of.get(par, par)), "Duplicate")
_link_copy(tree, "obj:" + dot, "obj:" + String(copy_of[dot]))
_persist.end_gesture()
if made.is_empty():
print("[duplicate] nothing could be copied")
return false
select_objects(made, made[-1])
print("[duplicate] %d object(s)" % made.size())
elif _sel_asset != "":
# A PLACED ASSET (#120 L4): a new placement of the same asset, same collections —
# Shift+D and Alt+D alike (every placement is already linked data).
var srca := _sel_asset
_persist.begin_gesture("Duplicate")
var da := duplicate_asset(srca)
_persist.end_gesture()
if da == "":
print("[duplicate] the journal refused the placement copy")
return false
_persist.refold()
select_asset(da)
print("[duplicate] placed asset %s -> %s" % [srca, da])
elif _sel_shape != "":
var src := _sel_shape
_persist.begin_gesture("Duplicate")
var sd := _dup_shape(tree, src, linked)
_persist.end_gesture()
if sd == "":
print("[duplicate] the journal refused the shape copy")
return false
_persist.refold()
select_shape(sd)
print("[duplicate] shape %s -> %s" % [src, sd])
elif not _sel_brushes.is_empty():
# A SHAPE'S PIECES among the brushes copy as their SHAPE, each once (as
# delete_brushes deletes them) — skipped, a staircase selected as its pieces
# copied nothing and Move then took the ORIGINAL (user 2026-10-17: "shift d seems
# to just move").
var sp: Array = _split_shapes(_sel_brushes)
var shapes_made: Array[String] = []
var keys: Array[String] = [] # the brush copies, by durable key
var new_group := {} # source group -> the copies' group
_persist.begin_gesture("Duplicate")
for d: String in (sp[0] as Dictionary):
var sd2 := _dup_shape(tree, d, linked)
if sd2 != "":
shapes_made.append(sd2)
if linked and not (sp[1] as Array).is_empty():
print("[duplicate] linked: %d plain brush(es) skipped — they have no shared data; Make parametric first"
% (sp[1] as Array).size())
for b: int in ([] if linked else sp[1] as Array):
var p: Dictionary = _persist._brush_payload(level.get_brush(b))
var g := String(level.get_brush_group(b))
if g != "" and not new_group.has(g):
new_group[g] = _persist.reserve_dot()
p["group"] = new_group.get(g, "")
var nid := _create_brush_journalled(p)
if nid > 0:
keys.append(String(level.get_brush_key(nid)))
for g: String in new_group:
_link_copy(tree, "g:" + g, "g:" + String(new_group[g]))
_persist.end_gesture()
if keys.is_empty() and shapes_made.is_empty():
print("[duplicate] nothing could be copied")
return false
if shapes_made.size() == 1 and keys.is_empty():
_persist.refold()
select_shape(shapes_made[0])
else:
if not shapes_made.is_empty():
_persist.refold() # the shapes' pieces exist after a fold
level.rebuild()
_refresh_brush_view()
# A fold can renumber brush ids: the copies are found again by key.
var ids := PackedInt32Array()
for k: String in keys:
var nb: int = level.brush_by_key(k)
if nb >= 0:
ids.append(nb)
for sd3: String in shapes_made:
ids.append_array(level.get_group_brush_ids(sd3))
select_brushes(ids)
print("[duplicate] %d brush(es), %d shape(s)" % [keys.size(), shapes_made.size()])
else:
print("[duplicate] select something first")
return false
return true
## Shift+D / Alt+D: copy what is selected, then MOVE the copies (Blender's). THE UNION
## (§17.10): each kind is copied in turn inside ONE gesture — objects, curves, then the
## brushes LAST (the other copies' folds renumber brush ids, so the source brushes are
## found again by key) — and all the copies are selected together.
func duplicate_selection(linked: bool = false) -> bool:
var kinds := int(not _sel_brushes.is_empty()) + int(not _sel_objects.is_empty()) + int(not _sel_curves.is_empty())
var ok := false
if kinds > 1 or _sel_curves.size() > 1:
ok = _duplicate_union(linked)
else:
ok = _duplicate_one(linked)
if ok and _ensure_ops().activate(_move):
print("[MapDCLevel] Move the copy: look to drag, X/Y/Z axis, Enter to place, Esc leaves it in place")
return ok
func _duplicate_union(linked: bool) -> bool:
_ensure_persistence()
var objs: Array = _sel_objects.duplicate()
var curves: Array = _sel_curves.duplicate()
var shape := _sel_shape
var asset := _sel_asset
var keys: Array = []
for b: int in _sel_brushes:
keys.append(String(level.get_brush_key(b)))
var made_objs: Array = []
var made_curves: Array = []
_persist.begin_gesture("Duplicate")
if not objs.is_empty():
select_objects(objs, String(objs[-1]))
if _duplicate_one(linked):
made_objs = selected_objects()
for c: String in curves:
var cd := duplicate_curve(c)
if cd != "":
made_curves.append(cd)
var got_brushes := false
if not keys.is_empty():
if asset != "":
select_asset(asset)
elif shape != "":
select_shape(shape)
else:
var ids := PackedInt32Array()
for k: String in keys:
var nb: int = level.brush_by_key(k)
if nb >= 0:
ids.append(nb)
select_brushes(ids)
got_brushes = _duplicate_one(linked)
_persist.end_gesture()
if not got_brushes:
_clear_face_selection()
for d: String in made_objs:
select_object(d, true)
for c2: String in made_curves:
select_curve(c2, true)
print("[duplicate] union: %d object(s), %d curve(s), brushes %s" % [made_objs.size(), made_curves.size(),
"copied" if got_brushes else "none"])
return got_brushes or not made_objs.is_empty() or not made_curves.is_empty()