OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / quake_map_importer.gd

842 lines · gd ref:
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