class_name QuakeMapImporter
extends RefCounted
## Phase-0 Quake .MAP → dc-terrain importer (AABB approximation).
##
## Parses a standard-format Quake .MAP (id Quake, not Valve220), and for each
## worldspawn brush recovers its vertices from plane-triple intersections, takes
## the axis-aligned bounding box, and stamps it into a DCTerrain as an additive
## box edit. Skybox / clip / trigger brushes are filtered out.
##
## This is the COARSE first cut (every brush becomes an axis-aligned box) — it
## proves the load / scale / coordinate / material-id pipeline end to end. The
## exact convex-SDF primitive (max_i(n_i·p − d_i)) and dense brick import are the
## next phases — see dc-terrain/docs/quake-import-texturing.md.
##
## Verified against START.MAP: 1156 worldspawn brushes, 1123 with recoverable
## AABB, 0 degenerate, 33 sky/clip-only skipped.
const EPS := 1.0e-3
const SKIP_PREFIXES := ["SKY", "CLIP", "TRIGGER", "SKIP", "HINT", "__TB"]
## texture name -> material_id (1-based; 0 = untextured). Built during import;
## material_names[id-1] gives the WAD texture name for the future atlas step.
var material_ids := {} # String -> int
var material_names: Array[String] = []
## Per-face texture-projection table (Stage 2, per-face GPU channel). Flat float buffer,
## 12 floats (3 vec4 texels) per entry, index = per-face projection index:
## texel0 = (layer, u_off, v_off, 0) layer = atlas layer (material_id-1)
## texel1 = (u_axis.xyz, 0) u_tile = u_axis·world_pos + u_off (already ÷texW)
## texel2 = (v_axis.xyz, 0) v_tile = v_axis·world_pos + v_off (already ÷texH)
## The DC shader carries (proj_index+1) per vertex (0 = none → axial fallback) and samples
## tex_atlas[layer] at fract(vec2(u_tile, v_tile)). Uploaded via terrain.set_projection_table.
var projections: PackedFloat32Array = PackedFloat32Array()
## Liquid volumes recovered from the map (water / lava / slime), in FINAL world space
## (same recenter+scale+offset transform as the solid brushes). Each entry:
## {class, tex, center, half, aabb_min, aabb_max, top_y}
## The caller spawns a LiquidVolume (warped surface + swim + screen blend) per entry.
## Populated by import_text; reset on every import. Teleport brushes go to
## `teleport_volumes` (same shape) for the separate teleporter feature — NOT surfaced here.
var liquid_volumes: Array = []
var teleport_volumes: Array = []
## Sky brushes in FINAL world space (only populated when include_sky is on). Each:
## {center, half, mat} — mat = 1-based material id, so atlas layer = mat-1.
## These are the level's authored boundary, so they double as the placement data
## for sky AreaLight3Ds: a sky brush is a box, and its inward face is a rectangle
## an AreaLight3D can match exactly (area_size is in metres).
var sky_volumes: Array = []
## Teleporter wiring: trigger_teleport touch volumes (world-space AABB + entity target) and
## info_teleport_destination points (targetname -> {pos: Vector3 world, yaw: float}).
var teleport_triggers: Array = []
var teleport_destinations: Dictionary = {}
## The map's recenter+scale transform, exposed so the caller can place rtlights / other Quake
## coords into the SAME world space as the geometry: world = ((_q2g(qv) - map_center)*scale)+offset.
var out_map_center: Vector3 = Vector3.ZERO
var out_aligned_offset: Vector3 = Vector3.ZERO
## The world_scale the last import ran with — the third term of that same transform.
## Recorded so post-import helpers (player_start) can place Quake coords without the
## caller having to pass the scale back in and risk it disagreeing with the geometry.
var _last_world_scale: float = 0.1
## func_door movers (materialised as animated models, not SDF). Each entry (world space):
## {center, half, tex, move_dir, move_distance, speed, wait, targetname}
var doors: Array = []
## Faces parsed in Valve 220 syntax during the last import. Reported rather than
## inferred: the two formats look identical once loaded, so without a count there
## is nothing to tell you which one a map was authored in — and being wrong about
## that is invisible in the geometry and obvious only in the texturing.
var valve_faces: int = 0
# --- public -----------------------------------------------------------------
## Import a .MAP file into `terrain` (a DCTerrain node).
## world_scale — Quake units → world units (START extent ≈ 2944×3056×1235 u).
## world_offset — added AFTER scaling+recentring; drops the map at this point.
## strength — additive box edit strength (>0 = solid).
## light_parent — if set, spawn an OmniLight3D per Quake light entity under it.
## carve — subtract the map's bounding box from the terrain FIRST, so the
## imported level sits in a carved void instead of unioning with
## the procedural hill.
## Returns the number of brushes stamped.
## tex_dims — {lowercased texture name: Vector2i(w,h)} from QuakeWadLoader.load_dims, so
## per-face UV projections normalise to tile units. Empty = fall back to 64×64 (neutral).
## include_sky — treat sky brushes as SOLID geometry instead of dropping them.
## In Quake a sky brush is solid (it blocks the player) and its faces are drawn as
## sky; dropping them leaves holes where the map's ceiling/boundary should be. The
## DCTerrain path has always dropped them, so this defaults FALSE and that path is
## unchanged; the exact-mesh level opts in.
func import_file(terrain: Node, path: String, world_scale: float = 0.1,
world_offset: Vector3 = Vector3.ZERO, strength: float = 1.0,
light_parent: Node = null, carve: bool = true,
tex_dims: Dictionary = {}, include_sky: bool = false,
field: Node = null) -> int:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
push_error("[QuakeImport] cannot open %s" % path)
return 0
var text := f.get_as_text()
f.close()
return import_text(terrain, text, world_scale, world_offset, strength, light_parent, carve, tex_dims, include_sky, field)
## `terrain` is the BRUSH SINK; `field` is the SDF the carve is subtracted from.
## They were one node until §5a.1, and that is why the integrated configuration
## did not exist: the sink is chosen by `add_brush` and the carve by
## `edit_terrain_box`, DCLevel has only the first and DCTerrain only the second,
## so a single node could satisfy one or the other and never both. Passing the
## level as the sink therefore silently disabled the interior protection.
## Defaults to null = the old behaviour exactly (carve on the sink if it can),
## so both existing callers keep their meaning without being touched.
func import_text(terrain: Node, text: String, world_scale: float,
world_offset: Vector3, strength: float,
light_parent: Node = null, carve: bool = true,
tex_dims: Dictionary = {}, include_sky: bool = false,
field: Node = null) -> int:
var brushes := _parse_all_brushes(text)
if brushes.is_empty():
push_warning("[QuakeImport] no brushes parsed")
return 0
# Fresh per import — the caller reads these members after import_file returns.
liquid_volumes = []
teleport_volumes = []
sky_volumes = []
teleport_triggers = []
teleport_destinations = {}
doors = []
valve_faces = 0
# First pass: classify each brush by Quake CONTENT (from its texture, per
# hmap2/brush.c) and collect the SOLID ones' AABBs + planes. Content routing:
# solid → convex SDF edit (render + collision), as before.
# water/lava/slime → NON-solid: collected as a VOLUME (Area3D + buoyancy/
# damage come next), NOT stamped — so the player no
# longer stands on the liquid surface ("floating mode").
# teleport → NON-solid trigger volume (teleporter, wired later).
# sky/clip/trigger/discard → dropped from the SDF (sky = skybox boundary,
# clip = collision-only TODO, trigger = touch volume).
# Brushes now come from ALL entities, so func_wall/func_door/gates (solid) are
# recovered — the previously-missing brush-entity geometry.
var boxes := [] # {center, half, planes, mat}
var volumes := [] # {class, center, half} (non-solid)
var tp_triggers := [] # {center, half, target} (trigger_teleport)
var door_entities := {} # entity_idx -> {keys, parts:[{center,half}], tex}
var content_counts := {}
var gmin := Vector3(INF, INF, INF)
var gmax := Vector3(-INF, -INF, -INF)
var skipped := 0
var degenerate := 0
var sky_solid := 0 # sky brushes promoted to solid
for item in brushes:
var faces: Array = item.faces
# trigger_teleport touch volumes carry the TRIGGER texture (dropped by content class),
# but the entity classname marks them — collect the AABB + target for teleporter wiring.
if String(item.get("classname", "")) == "trigger_teleport":
var tb := _brush_aabb(faces)
if not tb.is_empty():
tp_triggers.append({"center": tb.center, "half": tb.half, "target": String(item.get("target", ""))})
skipped += 1
continue
# func_door: a moving brush → materialise as an animated model, NOT stamped into the SDF.
# Group by ENTITY so a multi-brush door moves as ONE unit (Quake computes the travel from
# the whole door's bbox and moves every brush together).
if String(item.get("classname", "")) == "func_door":
var db := _brush_aabb(faces)
if not db.is_empty():
var eid: int = int(item.get("entity", -1))
if not door_entities.has(eid):
door_entities[eid] = {"keys": item.get("keys", {}), "parts": [],
"tex": _dominant_texture(faces)}
(door_entities[eid].parts as Array).append({"center": db.center, "half": db.half})
skipped += 1
continue
var cc: String = _content_class(_dominant_texture(faces))
content_counts[cc] = int(content_counts.get(cc, 0)) + 1
# Sky is SOLID in Quake — it blocks the player, and only its RENDERING is
# special (the skybox is drawn through it). Dropping it punches holes in
# the map boundary. Promote it to solid so the level is sealed; the sky
# TEXTURE then comes through the normal material path.
var is_sky := false
if cc == "sky" and include_sky:
cc = "solid"
is_sky = true
sky_solid += 1
if cc != "solid":
if cc == "water" or cc == "lava" or cc == "slime" or cc == "teleport":
var vb := _brush_aabb(faces)
if not vb.is_empty():
volumes.append({"class": cc, "center": vb.center, "half": vb.half,
"tex": _dominant_texture(faces)})
skipped += 1
continue
var box := _brush_aabb(faces)
if box.is_empty():
degenerate += 1
continue
# Collect the brush's face planes (outward normal, inside = n·p <= d) for the
# convex primitive — already in Godot space (_q2g applied at parse). Keep the
# generating face alongside each plane (lockstep) so the per-face UV projection
# can be built in the stamping pass, once map_center/aligned_offset are known.
var planes := []
var pfaces := []
for face in faces:
var pl := _face_plane(face)
if not pl.is_empty():
planes.append(pl)
pfaces.append(face)
if planes.size() < 4:
degenerate += 1
continue
boxes.append({
"center": box.center, "half": box.half, "planes": planes, "faces": pfaces,
"mat": (_material_for_sky(_dominant_texture(faces)) if is_sky
else _material_for(_dominant_texture(faces))), "sky": is_sky,
})
gmin = gmin.min(box.center - box.half)
gmax = gmax.max(box.center + box.half)
# GRID-ALIGN to Quake's 8-unit grid: with DCTerrain voxel_size == world_scale*8,
# axis-aligned brush faces on the 8-grid land EXACTLY on voxel boundaries → DC
# resolves them with no sub-voxel wobble (91.7% of START's faces). Snap the map
# centre to the 8-grid and the offset to a voxel boundary so alignment holds.
var vox := world_scale * 8.0
var map_center := (gmin + gmax) * 0.5
map_center = (map_center / 8.0).round() * 8.0
var aligned_offset: Vector3 = (world_offset / vox).round() * vox
out_map_center = map_center
out_aligned_offset = aligned_offset
_last_world_scale = world_scale
# Transform the collected liquid/teleport volumes into FINAL world space with the SAME
# recenter+scale+offset the solid brushes get below, so surfaces/swim boxes line up 1:1
# with the carved geometry. AABB is preserved (uniform scale, no rotation → the axis-
# aligned box stays axis-aligned). top_y = surface plane height (top face of the volume).
for v in volumes:
var wc: Vector3 = (((v.center as Vector3) - map_center) * world_scale) + aligned_offset
var wh: Vector3 = (v.half as Vector3) * world_scale
var entry := {
"class": v.get("class", "water"), "tex": v.get("tex", ""),
"center": wc, "half": wh, "aabb_min": wc - wh, "aabb_max": wc + wh,
"top_y": wc.y + wh.y,
}
if v.get("class", "") == "teleport":
teleport_volumes.append(entry)
else:
liquid_volumes.append(entry)
for t in tp_triggers:
var tc: Vector3 = (((t.center as Vector3) - map_center) * world_scale) + aligned_offset
var th: Vector3 = (t.half as Vector3) * world_scale
teleport_triggers.append({"aabb_min": tc - th, "aabb_max": tc + th, "target": t.get("target", "")})
teleport_destinations = _parse_teleport_destinations(text, map_center, world_scale, aligned_offset)
for eid in door_entities:
var de: Dictionary = door_entities[eid]
var parts: Array = de.parts
if parts.is_empty():
continue
# Combined AABB of ALL the door's brushes — Quake computes the travel from the whole door.
var dmin := Vector3(INF, INF, INF)
var dmax := Vector3(-INF, -INF, -INF)
for p in parts:
dmin = dmin.min((p.center as Vector3) - (p.half as Vector3))
dmax = dmax.max((p.center as Vector3) + (p.half as Vector3))
var keys: Dictionary = de.keys
var ang := float(keys.get("angle", "0"))
# Quake func_door move direction: 0-359 = XY heading, -1 = up, -2 = down.
var mdir: Vector3
if ang == -1.0:
mdir = Vector3.UP
elif ang == -2.0:
mdir = Vector3.DOWN
else:
var ar := deg_to_rad(ang)
mdir = _q2g(cos(ar), sin(ar), 0.0).normalized()
# World-space parts (offsets relative to the door origin) + combined box for travel.
var origin: Vector3 = ((((dmin + dmax) * 0.5) - map_center) * world_scale) + aligned_offset
var world_parts := []
for p in parts:
var pc: Vector3 = (((p.center as Vector3) - map_center) * world_scale) + aligned_offset
world_parts.append({"offset": pc - origin, "half": (p.half as Vector3) * world_scale})
var full_size: Vector3 = (dmax - dmin) * world_scale
var size_along := absf(full_size.dot(mdir))
var lip := float(keys.get("lip", "8")) * world_scale
# Quake door triggering (doors.qc): a door with a KEY spawnflag (GOLD 8 / SILVER 16) needs
# the key, and a door with a targetname is opened by a target EVENT (button/trigger), not
# by touch. We have neither keys nor buttons yet, so both stay CLOSED (their default) —
# only plain doors open on proximity. This is why targetname doors looked "not in place".
var sf := int(float(keys.get("spawnflags", "0")))
var tname := String(keys.get("targetname", ""))
var locked := tname != "" or (sf & 8) != 0 or (sf & 16) != 0
doors.append({
"origin": origin, "parts": world_parts, "tex": de.tex,
"move_dir": mdir, "move_distance": maxf(size_along - lip, 0.0),
"speed": float(keys.get("speed", "100")) * world_scale,
"wait": float(keys.get("wait", "3")),
"targetname": tname, "locked": locked,
})
# Which sink are we feeding? DCLevel (exact brush level) and DCTerrain (voxel
# clipmap) take the SAME flat (nx,ny,nz,d) plane layout + parallel per-face
# projection indices — dc_level.h states that symmetry explicitly — so ONE
# importer drives either. Keeping a single plane transform is the point: the
# grid-align + recenter below decides where geometry lands, and a second copy
# of it would drift from this one. Duck-typed rather than class-checked so this
# file keeps no hard dependency on the GDExtension being loaded.
var to_level := terrain.has_method("add_brush")
# Carve FIRST: subtract the map's (inflated) AABB so the level occupies a void
# rather than fusing with the terrain. Must precede the additive brushes.
#
# THE CARVE GOES TO `field`, WHICH IS ONLY THE SINK WHEN NOTHING ELSE IS GIVEN.
# For a standalone arena there is no field at all and the carve is meaningless —
# level-mesh-implementation.md §5a's escape hatch ("the level<->world seam
# vanishes"). For the integrated world it is the ONLY thing keeping the
# procedural hill out of the level's rooms until §5a.2's convex decomposition
# of the interior lands, at which point this inflated box is replaced by a set
# of negative brushes and NOT merely deleted. Deleting it on its own is the
# documented way to fill the level with terrain.
#
# Still guarded on the method, so a caller passing carve=true out of habit with
# no field cannot fault the level path.
var carve_into: Node = field if field != null else terrain
if carve and carve_into.has_method("edit_terrain_box"):
var carve_half: Vector3 = (gmax - gmin) * 0.5 * world_scale * 1.1
carve_into.edit_terrain_box(aligned_offset, carve_half, -absf(strength), false, 0)
var stamped := 0
for b in boxes:
var c: Vector3 = ((b.center - map_center) * world_scale) + aligned_offset
var radius: float = (b.half as Vector3).length() * world_scale * 1.05
# Transform each face plane by recenter+scale+offset. Normal unchanged
# (uniform scale, no rotation); d' = n·offset + s·(d − n·map_center). Build the
# parallel per-face projection index (Stage 2): each plane carries the atlas
# projection of ITS OWN face, so the bake can pick the dominant face's texture.
var flat := PackedFloat32Array()
var face_proj := PackedInt32Array()
var bfaces: Array = b.faces
for i in b.planes.size():
var pl = b.planes[i]
var nrm: Vector3 = pl.n
var d2: float = nrm.dot(aligned_offset) + world_scale * (float(pl.d) - nrm.dot(map_center))
flat.append(nrm.x); flat.append(nrm.y); flat.append(nrm.z); flat.append(d2)
face_proj.append(_register_face_projection(bfaces[i], world_scale, map_center, aligned_offset, tex_dims, b.mat))
if to_level:
# EXACT path: the brush IS the geometry. No SDF, no voxel, no snap — the
# plane set goes into the brush store and csg4 derives the visible skin
# from it (level-mesh-implementation.md §6.1). `c`/`radius` are voxel-lane
# concepts and have no meaning here. level_id 0 = the single level this
# room hosts; §9a.2 leaves multi-level identity unsettled.
# sky: clips like any brush, but no collider (jump through it) and it
# marks the level's authored outside wall.
var brush_is_sky: bool = bool(b.get("sky", false))
terrain.add_brush(flat, b.mat, face_proj, 0, brush_is_sky)
if brush_is_sky:
# Same recenter+scale+offset the geometry got, so a light placed
# from this lines up with the rendered face to float precision.
sky_volumes.append({"center": c, "half": (b.half as Vector3) * world_scale,
"mat": int(b.mat)})
else:
# interactive=false → landscape lane. The convex primitive gets the plane-snap
# → sharp Quake faces/edges. material_id (dominant, flat-color/v_is_brush) +
# per-face projection indices both ride the edit (route ②).
terrain.edit_terrain_convex(flat, c, radius, strength, false, b.mat, face_proj)
stamped += 1
var lit := 0
if light_parent != null:
lit = _spawn_lights(light_parent, text, map_center, world_scale, aligned_offset)
print("[QuakeImport] stamped=%d (sky_solid=%d) skipped=%d degenerate=%d liquids=%d teleports=%d materials=%d lights=%d format=%s carve=%s content=%s extent(u)=%s" % [
stamped, sky_solid, skipped, degenerate, liquid_volumes.size(), teleport_volumes.size(),
material_names.size(), lit,
("standard" if valve_faces == 0 else "valve220(%d faces)" % valve_faces),
str(carve), str(content_counts), str((gmax - gmin).round())])
return stamped
# --- parsing ----------------------------------------------------------------
## Standard-format line parser. Returns Array of brushes across ALL entities;
## each element = {faces: Array, classname: String}. Brush-entities (func_wall /
## func_door / gates / trigger_*) carry real solid geometry that worldspawn-only
## parsing dropped — a major source of "missing brushes". The classname is kept
## per brush so entity behaviour (doors, teleporters) can attach later; content
## brushes (trigger/sky/clip) are still filtered downstream by texture.
func _parse_all_brushes(text: String) -> Array:
var brushes := []
var depth := 0
var cur_faces := []
var cur_keys := {} # all depth-1 entity keyvals for the current entity
var entity_idx := -1 # which entity a brush belongs to (for grouping door parts)
for raw in text.split("\n", false):
var line := raw.strip_edges()
if line.is_empty() or line.begins_with("//"):
continue
if line == "{":
depth += 1
if depth == 2:
cur_faces = []
elif depth == 1:
cur_keys = {}
entity_idx += 1
continue
if line == "}":
if depth == 2:
brushes.append({"faces": cur_faces, "classname": cur_keys.get("classname", ""),
"target": cur_keys.get("target", ""), "keys": cur_keys.duplicate(), "entity": entity_idx})
depth -= 1
continue
# entity-level "classname" "value" (depth 1) tags the brushes that follow
if depth == 1 and line.begins_with("\""):
var parts := line.split("\"", false)
if parts.size() >= 3:
cur_keys[String(parts[0])] = String(parts[2])
# a face line lives at depth 2 (inside any entity's brush)
elif depth == 2 and line.begins_with("("):
var face := _parse_face(line)
if not face.is_empty():
cur_faces.append(face)
return brushes
## One face line, in EITHER of the two syntaxes Quake accepts:
##
## ( p1 ) ( p2 ) ( p3 ) TEX xoff yoff rot sx sy Standard
## ( p1 ) ( p2 ) ( p3 ) TEX [ ux uy uz uoff ] [ vx vy vz voff ] r s s Valve 220
##
## TrenchBroom's Quake config offers both and lists Valve first, so a map authored
## today may well be Valve — and this used to strip only ( and ), leaving token 10
## as the literal "[". xoff became 0 and yoff/rot/sx/sy picked up the U AXIS
## COMPONENTS. Nothing errored: the geometry was right and the texturing was
## quietly wrong, which for four of six faces of a plain cube looked almost
## correct (scenes/test/valve220_gate.gd measures exactly that).
##
## DETECTED PER LINE, not per file. The format is a property of the face syntax,
## the two never appear in one file in practice, and a "mapversion" key would be
## another thing to trust — the brackets are the evidence.
func _parse_face(line: String) -> Dictionary:
var valve := line.find("[") >= 0
var t := line.replace("(", " ").replace(")", " ") \
.replace("[", " ").replace("]", " ").replace("\t", " ").split(" ", false)
if t.size() < 10:
return {}
var nums: Array[float] = []
for i in range(9):
nums.append(float(t[i]))
var xoff := 0.0
var yoff := 0.0
var rot := 0.0
var sx := 1.0
var sy := 1.0
var u_axis := Vector3.ZERO
var v_axis := Vector3.ZERO
if valve:
# TEX [ ux uy uz uoff ] [ vx vy vz voff ] rot sx sy — the shifts are the
# same quantity as Standard's xoff/yoff, so they land in the same fields
# and everything downstream of the axes is shared.
if t.size() < 21:
return {}
u_axis = Vector3(float(t[10]), float(t[11]), float(t[12]))
xoff = float(t[13])
v_axis = Vector3(float(t[14]), float(t[15]), float(t[16]))
yoff = float(t[17])
rot = float(t[18])
sx = float(t[19])
sy = float(t[20])
# A zero axis would collapse the projection to a point. Fall back to the
# axial rule for that face rather than emitting a degenerate one.
if u_axis.length_squared() < 1.0e-12 or v_axis.length_squared() < 1.0e-12:
valve = false
else:
# TEX xoff yoff rot xscale yscale
xoff = float(t[10]) if t.size() > 10 else 0.0
yoff = float(t[11]) if t.size() > 11 else 0.0
rot = float(t[12]) if t.size() > 12 else 0.0
sx = float(t[13]) if t.size() > 13 else 1.0
sy = float(t[14]) if t.size() > 14 else 1.0
if sx == 0.0: sx = 1.0
if sy == 0.0: sy = 1.0
if valve:
valve_faces += 1
# Quake is Z-up, Godot is Y-up → rotate -90° about X: (x,y,z)_q → (x,z,-y)_g.
# Proper rotation (det +1) so brush winding — and the AABB inside-test — hold.
return {
"p1": _q2g(nums[0], nums[1], nums[2]),
"p2": _q2g(nums[3], nums[4], nums[5]),
"p3": _q2g(nums[6], nums[7], nums[8]),
"tex": String(t[9]),
"xoff": xoff, "yoff": yoff, "rot": rot, "sx": sx, "sy": sy,
# QUAKE space, unscaled: _face_texproj divides by sx/sy exactly as the
# axial rule does, so the two paths differ only in where the axes came from.
"valve": valve, "u_axis": u_axis, "v_axis": v_axis,
}
## Quake (Z-up) → Godot (Y-up) coordinate rotation. Single source of truth so
## brush geometry and light origins convert identically.
static func _q2g(x: float, y: float, z: float) -> Vector3:
return Vector3(x, z, -y)
# --- Quake texture projection (faithful UV) — piece 2 of texturing -----------
# Quake axial base axes (Z-up): 6 groups of {facenormal, uaxis, vaxis}.
const _BASEAXIS := [
Vector3(0,0,1), Vector3(1,0,0), Vector3(0,-1,0), # floor
Vector3(0,0,-1), Vector3(1,0,0), Vector3(0,-1,0), # ceiling
Vector3(1,0,0), Vector3(0,1,0), Vector3(0,0,-1), # west
Vector3(-1,0,0), Vector3(0,1,0), Vector3(0,0,-1), # east
Vector3(0,1,0), Vector3(1,0,0), Vector3(0,0,-1), # south
Vector3(0,-1,0), Vector3(1,0,0), Vector3(0,0,-1), # north
]
static func _rot2(vec: Vector3, su: int, tu: int, sn: float, cs: float) -> Vector3:
var c := [vec.x, vec.y, vec.z]
var a: float = c[su]; var b: float = c[tu]
c[su] = cs * a - sn * b
c[tu] = sn * a + cs * b
return Vector3(c[0], c[1], c[2])
## Quake standard axial texture axes (in QUAKE space) for a quake-space normal `qn`,
## with the .MAP rotation (deg) and scale applied. Returns [u_axis, v_axis].
static func _quake_texvecs(qn: Vector3, rot: float, sx: float, sy: float) -> Array:
var best := -1.0; var bi := 0
for i in range(6):
var d := qn.dot(_BASEAXIS[i * 3])
if d > best: best = d; bi = i
var u: Vector3 = _BASEAXIS[bi * 3 + 1]
var v: Vector3 = _BASEAXIS[bi * 3 + 2]
var ang := deg_to_rad(rot); var sn := sin(ang); var cs := cos(ang)
var su := 0 if u.x != 0.0 else (1 if u.y != 0.0 else 2)
var tu := 0 if v.x != 0.0 else (1 if v.y != 0.0 else 2)
u = _rot2(u, su, tu, sn, cs)
v = _rot2(v, su, tu, sn, cs)
return [u / sx, v / sy]
## Build the per-face atlas projection for `face`, append it to `projections`, and return
## its 1-BASED index (0 = none). Folds the texture's real width/height into the axes so the
## shader samples fract(vec2(u_axis·wp+u_off, v_axis·wp+v_off)) directly on the atlas layer
## (which was resized to a square, discarding the aspect — so tiling MUST use the original
## texW/texH). `brush_mat` is the fallback layer for a face whose own texture is untextured
## (skip/clip on a solid brush). Entry = 3 vec4 texels (layer,u_off,v_off,0)(u_axis,0)(v_axis,0).
func _register_face_projection(face: Dictionary, world_scale: float, map_center: Vector3,
aligned_offset: Vector3, tex_dims: Dictionary, brush_mat: int) -> int:
var fmat: int = _material_for(face.tex)
if fmat == 0:
fmat = brush_mat
var layer: int = maxi(fmat - 1, 0)
var dim: Vector2i = tex_dims.get(String(face.tex).to_lower(), Vector2i(64, 64))
var texw: float = float(dim.x) if dim.x > 0 else 64.0
var texh: float = float(dim.y) if dim.y > 0 else 64.0
var pr := _face_texproj(face, world_scale, map_center, aligned_offset)
var ua: Vector3 = pr.u_axis / texw
var uo: float = float(pr.u_off) / texw
var va: Vector3 = pr.v_axis / texh
var vo: float = float(pr.v_off) / texh
# texel3 = the face's GEOMETRIC normal (godot space). The shader's crease-pick matches the
# pixel normal against THIS, not cross(u_axis,v_axis) — Quake projects diagonal faces with axial
# texture axes, so the texture normal is wrong for them; the geometric normal is always correct.
var gn: Vector3 = _face_plane(face).get("n", Vector3(0, 0, 1)).normalized()
projections.append_array(PackedFloat32Array([
float(layer), uo, vo, 0.0,
ua.x, ua.y, ua.z, 0.0,
va.x, va.y, va.z, 0.0,
gn.x, gn.y, gn.z, 0.0,
]))
return projections.size() / 16 # 1-based index of the entry just appended (4 texels/entry)
## Collapsed Godot-space, texel-unit projection for a face: {u_axis, u_off, v_axis, v_off}
## with u_texels = u_axis·world_pos + u_off (shader divides by real texW/texH). Collapses
## the Q→G rotation + world_scale/map_center/aligned_offset of the geometry transform
## gv = (_q2g(qv) - map_center)*world_scale + aligned_offset. Verified vs direct Quake UV.
func _face_texproj(face: Dictionary, world_scale: float, map_center: Vector3, aligned_offset: Vector3) -> Dictionary:
var pl := _face_plane(face)
var gn: Vector3 = pl.get("n", Vector3(0, 0, 1)) # godot-space normal
var qn := Vector3(gn.x, -gn.z, gn.y) # _q2g^-1 → quake normal
var qu: Vector3
var qv: Vector3
if bool(face.get("valve", false)):
# The axes are IN THE FILE. That is the whole point of Valve 220: they are
# not a function of the normal, so alignment survives rotation and shear,
# and no rule here can reproduce them — `rot` is already baked into them
# and is ignored, as qbsp ignores it for these faces.
qu = (face.u_axis as Vector3) / face.sx
qv = (face.v_axis as Vector3) / face.sy
else:
var vecs := _quake_texvecs(qn, face.rot, face.sx, face.sy)
qu = vecs[0]
qv = vecs[1]
var ru := _q2g(qu.x, qu.y, qu.z) # R·quake_u_axis (godot space)
var rv := _q2g(qv.x, qv.y, qv.z)
var mc_minus := map_center - aligned_offset / world_scale
return {
"u_axis": ru / world_scale, "u_off": mc_minus.dot(ru) + face.xoff,
"v_axis": rv / world_scale, "v_off": mc_minus.dot(rv) + face.yoff,
}
# --- geometry ---------------------------------------------------------------
## Plane from a face: outward normal (Quake CW-from-front winding) + distance d,
## with the convention n·x = d, inside-brush = n·x <= d. Returns {n,d} or {}.
func _face_plane(face: Dictionary) -> Dictionary:
# Extract typed locals — Dictionary access returns Variant, and GDScript
# refuses to infer (:=) a type from Variant-derived math.
var p1: Vector3 = face.p1
var p2: Vector3 = face.p2
var p3: Vector3 = face.p3
var n: Vector3 = (p3 - p1).cross(p2 - p1)
var l: float = n.length()
if l < 1.0e-9:
return {}
n /= l
return {"n": n, "d": n.dot(p1)}
## Intersection of three planes via Cramer's rule, or {} if near-parallel.
func _plane_intersect(a: Dictionary, b: Dictionary, c: Dictionary) -> Variant:
var n1: Vector3 = a.n; var n2: Vector3 = b.n; var n3: Vector3 = c.n
var d1: float = a.d; var d2: float = b.d; var d3: float = c.d
var denom: float = n1.dot(n2.cross(n3))
if abs(denom) < 1.0e-6:
return null
var p: Vector3 = (n2.cross(n3) * d1) + (n3.cross(n1) * d2) + (n1.cross(n2) * d3)
return p / denom
## AABB of a convex brush. Returns {center, half} or {} if degenerate.
func _brush_aabb(faces: Array) -> Dictionary:
var planes := []
for face in faces:
var pl := _face_plane(face)
if not pl.is_empty():
planes.append(pl)
if planes.size() < 4:
return {}
var vmin := Vector3(INF, INF, INF)
var vmax := Vector3(-INF, -INF, -INF)
var found := 0
var m := planes.size()
for i in range(m):
for j in range(i + 1, m):
for k in range(j + 1, m):
var v = _plane_intersect(planes[i], planes[j], planes[k])
if v == null:
continue
var inside := true
for pl in planes:
if pl.n.dot(v) > pl.d + EPS:
inside = false
break
if inside:
vmin = vmin.min(v)
vmax = vmax.max(v)
found += 1
if found < 4:
return {}
return {"center": (vmin + vmax) * 0.5, "half": (vmax - vmin) * 0.5}
# --- textures / materials ---------------------------------------------------
func _dominant_texture(faces: Array) -> String:
var hist := {}
for face in faces:
hist[face.tex] = int(hist.get(face.tex, 0)) + 1
var best := ""
var best_n := -1
for tex in hist:
if hist[tex] > best_n:
best_n = hist[tex]
best = tex
return best
## Quake CONTENT class from a texture name (authoritative rules: hmap2/brush.c).
## '*'-prefixed = non-solid turbulent volume (*lava / *slime / *teleport / else
## water); "sky*" = sky (rendered, no solid collision); "clip" = invisible solid;
## "trigger"/"skip"/"hint"/"null"/"__TB" = non-rendered special; everything else
## = solid. Returns one of: solid, water, lava, slime, teleport, sky, clip,
## trigger, discard.
func _content_class(tex: String) -> String:
var u := tex.to_upper()
if u.begins_with("*"):
var body := u.substr(1)
if body.begins_with("TELEPORT"):
return "teleport"
if body.begins_with("LAVA"):
return "lava"
if body.begins_with("SLIME"):
return "slime"
return "water"
if u.begins_with("SKY"):
return "sky"
if u == "CLIP":
return "clip"
if u.begins_with("TRIGGER"):
return "trigger"
if u.begins_with("SKIP") or u.begins_with("HINT") or u.begins_with("NULL") or u.begins_with("__TB"):
return "discard"
return "solid"
func _all_skipped(faces: Array) -> bool:
for face in faces:
if not _is_skipped(face.tex):
return false
return true
func _is_skipped(tex: String) -> bool:
var t := tex.to_upper().lstrip("*+")
for p in SKIP_PREFIXES:
if t.begins_with(p):
return true
return false
## Material id for a SKY texture. `_material_for` deliberately returns 0 for
## anything in SKIP_PREFIXES, and "SKY" is in that list — which was right while sky
## brushes were dropped, but wrong now that we import them: material 0 means
## "untextured", so the shader falls back to atlas layer 0 and a sky face draws some
## unrelated texture. Sky brushes need a REAL id both to render correctly and to
## give their AreaLight3D the right texture, so this registers the texture the same
## way as any other, bypassing only the skip test.
func _material_for_sky(tex: String) -> int:
if material_ids.has(tex):
return material_ids[tex]
var id := material_names.size() + 1
material_ids[tex] = id
material_names.append(tex)
return id
## Stable 1-based material id per distinct texture (0 reserved = untextured).
func _material_for(tex: String) -> int:
if _is_skipped(tex):
return 0
if material_ids.has(tex):
return material_ids[tex]
var id := material_names.size() + 1
material_ids[tex] = id
material_names.append(tex)
return id
# --- lights -----------------------------------------------------------------
## Spawn a SHADOWLESS OmniLight3D per Quake `light*` entity under `parent`,
## converted with the SAME transform as the brushes (so they line up). Shadows
## are off — START has ~265 lights and per-light shadow maps would tank FPS.
## Returns the count spawned.
func _spawn_lights(parent: Node, text: String, map_center: Vector3,
world_scale: float, world_offset: Vector3) -> int:
var container := Node3D.new()
container.name = "QuakeLights"
parent.add_child(container)
var count := 0
for ent in _parse_entities(text):
var cls: String = ent.get("classname", "")
if not cls.begins_with("light") or not ent.has("origin"):
continue
var o := String(ent["origin"]).split(" ", false)
if o.size() < 3:
continue
var q := _q2g(float(o[0]), float(o[1]), float(o[2]))
var lamp := OmniLight3D.new()
lamp.position = ((q - map_center) * world_scale) + world_offset
var brightness := float(ent.get("light", "200"))
lamp.omni_range = maxf(brightness * world_scale, 1.0)
lamp.light_energy = clampf(brightness / 200.0, 0.5, 4.0)
lamp.shadow_enabled = false # perf: ~265 lights, no shadow maps
if ent.has("_color"):
var c := String(ent["_color"]).split(" ", false)
if c.size() >= 3:
var r := float(c[0]); var g := float(c[1]); var bl := float(c[2])
if r > 1.0 or g > 1.0 or bl > 1.0: # Quake _color is 0-1 or 0-255
r /= 255.0; g /= 255.0; bl /= 255.0
lamp.light_color = Color(r, g, bl)
container.add_child(lamp)
count += 1
return count
## Parse every entity's depth-1 key/val pairs → Array of Dictionaries
## (String->String). Brush geometry (depth 2) is ignored.
func _parse_entities(text: String) -> Array:
var ents := []
var depth := 0
var cur := {}
for raw in text.split("\n", false):
var line := raw.strip_edges()
if line.is_empty() or line.begins_with("//"):
continue
if line == "{":
depth += 1
if depth == 1:
cur = {}
continue
if line == "}":
if depth == 1:
ents.append(cur)
depth -= 1
continue
if depth == 1 and line.begins_with("\""):
# "key" "value" — split on quotes: [key, <sep-ws>, value].
var parts := line.split("\"", false)
if parts.size() >= 3:
cur[String(parts[0])] = String(parts[2])
return ents
## Where Quake intends the player to stand: the `info_player_start` entity, in FINAL
## world space. Uses out_map_center / out_aligned_offset, so it is only valid AFTER
## an import has run (they are computed there). Returns {pos: Vector3, yaw: float},
## or {} if the map has no start (some maps only carry deathmatch starts).
##
## Quake puts the entity origin at the player's EYE/centre, not their feet, so the
## caller usually wants a small +Y nudge to avoid spawning interpenetrating the floor.
func player_start(text: String) -> Dictionary:
for ent in _parse_entities(text):
if String(ent.get("classname", "")) != "info_player_start":
continue
if not ent.has("origin"):
continue
var o := String(ent["origin"]).split(" ", false)
if o.size() < 3:
continue
var q := _q2g(float(o[0]), float(o[1]), float(o[2]))
var pos := ((q - out_map_center) * _last_world_scale) + out_aligned_offset
var a := deg_to_rad(float(ent.get("angle", "0")))
# Same yaw convention as _parse_teleport_destinations.
return {"pos": pos, "yaw": atan2(-cos(a), sin(a))}
return {}
## Parse info_teleport_destination entities → {targetname: {pos: Vector3 (world), yaw: float}}.
## Origin is transformed with the same recenter+scale+offset as the brushes/lights; the Quake
## "angle" (deg CCW from +X, Z-up) becomes the player's Godot yaw (camera_head.rotation.y).
func _parse_teleport_destinations(text: String, map_center: Vector3, world_scale: float,
aligned_offset: Vector3) -> Dictionary:
var out := {}
for ent in _parse_entities(text):
if String(ent.get("classname", "")) != "info_teleport_destination":
continue
var tn := String(ent.get("targetname", ""))
if tn == "" or not ent.has("origin"):
continue
var o := String(ent["origin"]).split(" ", false)
if o.size() < 3:
continue
var q := _q2g(float(o[0]), float(o[1]), float(o[2]))
var pos := ((q - map_center) * world_scale) + aligned_offset
var a := deg_to_rad(float(ent.get("angle", "0")))
# godot forward (cos a,0,-sin a); player yaw θ with forward = (-sinθ,0,-cosθ) → θ=atan2(-cos a, sin a)
var yaw := atan2(-cos(a), sin(a))
out[tn] = {"pos": pos, "yaw": yaw}
return out