class_name ArenaBoundary extends StaticBody3D # The standard arena play volume (inner faces of the enclosure). Every arena # instances objects/arena_boundary.tscn so all arenas share one size; code # that needs field dimensions derives them from these constants rather than # restating numbers. const INNER_HALF_X := 12.0 const INNER_HALF_Z := 18.0 const INNER_HEIGHT := 12.0 # Goal-centre distance from arena centre. Flush with the end walls (see # arena_01.tscn's goal transforms), so there is no floating gap between the # goal and the wall for a ball to ramp across before reaching the sensor. const GOAL_LINE_Z := INNER_HALF_Z # FLOOR (default) keeps the goal flush with the floor, as above. ELEVATED # moves the goal to GOAL_CENTER_Y — see the arena_0X_elevated.tscn scenes, # which bake that Y directly onto their Goal nodes and override this export. # There's no ramp: ships fly freely (see ship.gd), so nothing physically # needs to lead up to an elevated goal. enum GoalMode { FLOOR, ELEVATED } @export var goal_mode: GoalMode = GoalMode.FLOOR const GOAL_CENTER_Y := INNER_HEIGHT / 2.0 # ELEVATED mode goal centre # Curved transitions, so the ball rolls back into play instead of wedging # into a 90° pocket and ships can carry speed up the walls: quarter-cylinder # corner curves spanning the four vertical wall-wall edges, base fillets # easing the floor into every wall, and ceiling fillets easing every wall # into the ceiling. Everything is generated in _ready from these constants, # but collision and visuals deliberately differ: # - collision is rings of thick flat boxes tangent to the true arc — a # concave curve can't be one convex collider, primitives give Jolt clean # stable contact normals (the wall-contact reward reads them), and 1 m of # thickness is tunnel-proof at ball speeds; # - visuals are one merged shell (see _build_visual_shell) — proper curved # normals, and every surface drawn exactly once. # The base and ceiling fillets are the same skirting mirrored vertically, so # every function that builds one takes a `rise` of +1 (base, climbing away # from the floor) or -1 (ceiling, dropping away from it) and serves both. # The corner curves reach at most the chord plane # |x| + |z| = INNER_HALF_X + INNER_HALF_Z - CORNER_RADIUS. const CORNER_RADIUS := 4.0 const BASE_RADIUS := 2.0 # The end-wall fillets stop short of the goal mouth so floor-level shots # roll flat into the goal sensor (3.5 m wide) instead of ramping over it. const GOAL_MOUTH_HALF_WIDTH := 2.5 # The aperture the visual shell leaves for the goal itself, and the opaque # bulkhead surrounding it. Mirrors objects/goal.tscn's 3.5 x 1.5 sensor with a # little clearance, so no sliver of panel shows through the goal frame. The # surround has to be opaque hull: backed by the translucent field panel # instead, the goal's recess and net showed straight through the wall beside # the mouth and read as a second, duplicated net. const GOAL_APERTURE_HALF_WIDTH := 1.85 const GOAL_APERTURE_HEIGHT := 1.65 # ELEVATED only — in FLOOR mode the surround spans the deck to the fillet # tangent, so its height is BASE_RADIUS and needs no constant. const GOAL_SURROUND_HALF_HEIGHT := 1.6 # Flat collider segments per quarter arc. Max sag from the true curve is # R * (1 - cos(45° / N)): under 4 cm for both radii, invisible to the ball. const CORNER_SEGMENTS := 6 const BASE_SEGMENTS := 4 # Path segments carrying the fillet around each corner curve's base. const WRAP_SEGMENTS := 3 # Arc steps for the smooth visual surfaces (finer than the colliders; the # ball can sit at most ~4 cm proud of the drawn surface, which never reads). const CORNER_VISUAL_ARCS := 16 const FILLET_VISUAL_ARCS := 8 # Path steps for the torus patches carrying a fillet around a corner curve. const WRAP_VISUAL_ARCS := 8 # Match the boxes in arena_boundary.tscn: 1 m thick surfaces, walls spanning # y -1..12 (flush with the floor slab's bottom and the ceiling slab's top). const SURFACE_THICKNESS := 1.0 const WALL_HEIGHT := INNER_HEIGHT + 1.0 const WALL_CENTRE_Y := INNER_HEIGHT / 2.0 - 0.5 # Where the flat deck/ceiling stops and the fillets take over, and where the # fillets hand over to the wall panels. Every piece of the shell is cut to # these so no two surfaces overlap. const FLAT_HALF_X := INNER_HALF_X - BASE_RADIUS const FLAT_HALF_Z := INNER_HALF_Z - BASE_RADIUS const CORNER_CENTRE_X := INNER_HALF_X - CORNER_RADIUS const CORNER_CENTRE_Z := INNER_HALF_Z - CORNER_RADIUS const WRAP_RADIUS := CORNER_RADIUS - BASE_RADIUS const BASE_FILLET := 1.0 const CEILING_FILLET := -1.0 const FIELD_SHADER_PATH := "res://shaders/energy_field.gdshader" const DECK_SHADER_PATH := "res://shaders/arena_deck.gdshader" @export var field_tint := Color(0.45, 0.65, 1.0) @export var field_intensity := 0.09 @export var team0_tint := Color(0.15, 0.45, 1.0) @export var team1_tint := Color(1.0, 0.35, 0.25) # The single merged surface shell and the material whose camera-side fade # _process() drives. Both stay null in headless runs, which never render. var _shell: MeshInstance3D var _field_material: ShaderMaterial func _ready() -> void: add_to_group("arena_boundary") _build_corner_colliders() _build_fillet_colliders(BASE_FILLET) _build_fillet_colliders(CEILING_FILLET) # Visuals are pure decoration and training spawns many headless instances # that never render one, so skip the mesh build there entirely. Collision # is unaffected either way. if DisplayServer.get_name() == "headless": set_process(false) return _build_visual_shell() # Wall+ceiling-only proximity force field ("artificial gravity" grav-plating, # weaker than the floor's plain default gravity, which this deliberately # leaves untouched). Ship and Ball each call this with their own # strength/range so wall adherence and ceiling adherence can be tuned # independently per body — see ship.gd/ball.gd. Quadratic falloff keeps a # casual flyby near a wall almost force-free, concentrating the pull in # roughly the last third of the range so it only bites once something is # genuinely close to the surface. func get_surface_pull( global_pos: Vector3, wall_strength: float, wall_range: float, ceiling_strength: float, ceiling_range: float ) -> Vector3: var p := to_local(global_pos) var pull := Vector3.ZERO pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength pull += Vector3(-1, 0, 0) * _falloff(INNER_HALF_X + p.x, wall_range) * wall_strength # End walls are gated off inside the goal mouth — there is no physical # wall there (see GOAL_MOUTH_HALF_WIDTH / _fillet_runs), so a shot heading # straight for the net doesn't feel a phantom sideways tug. if abs(p.x) >= GOAL_MOUTH_HALF_WIDTH: pull += Vector3(0, 0, 1) * _falloff(INNER_HALF_Z - p.z, wall_range) * wall_strength pull += Vector3(0, 0, -1) * _falloff(INNER_HALF_Z + p.z, wall_range) * wall_strength pull += Vector3.UP * _falloff(INNER_HEIGHT - p.y, ceiling_range) * ceiling_strength return pull func _falloff(dist: float, field_range: float) -> float: var t: float = clamp(1.0 - dist / field_range, 0.0, 1.0) return t * t func _process(_delta: float) -> void: # The field shader fades out any facet the camera has crossed to the # outside of, so looking into the arena from outside stays clear. It does # that per-pixel from this one uniform, which is what lets the whole # enclosure be a single mesh instead of per-face MeshInstance3Ds toggled # individually. Collision is untouched. if _field_material == null: return var camera := get_viewport().get_camera_3d() if camera == null: return # headless (RL/CI) has no camera _field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position)) # --- shared layout ----------------------------------------------------------- # Consumed by both the collider rings and the visual shell, so the two can # never end up describing different geometry. func _corner_centre(sx: float, sz: float) -> Vector3: return Vector3(sx * CORNER_CENTRE_X, 0.0, sz * CORNER_CENTRE_Z) # The surface a fillet eases out of: the floor for the base run, the ceiling # for the mirrored one. func _fillet_base_y(rise: float) -> float: return 0.0 if rise > 0.0 else INNER_HEIGHT # Profile direction at `angle`, sweeping from facing the surface the fillet # eases out of (0) round to facing the wall (90°), pointing from the arc axis # into the fillet material. func _fillet_dir(wall_out: Vector3, angle: float, rise: float) -> Vector3: return wall_out * sin(angle) - Vector3.UP * (rise * cos(angle)) # Each run: horizontal unit vector toward its wall, unit direction along the # wall, centre of its arc axis, run length, and which ends (in +run_dir / # -run_dir order) are exposed and need a visual cap rather than meeting a # corner wrap. # # In FLOOR mode the base run along each end wall stops short of the goal mouth # (flat cutoff, capped) so floor-level shots roll flat into the goal sensor # instead of ramping over it — the goal sits flush with the wall there (see # GOAL_LINE_Z). Nothing else splits: an elevated goal isn't at floor level, and # the ceiling has nothing goal-related to keep clear, so both run full width. func _fillet_runs(rise: float) -> Array[Dictionary]: var centre_y := _fillet_base_y(rise) + rise * BASE_RADIUS var split := rise > 0.0 and goal_mode == GoalMode.FLOOR var side_run := 2.0 * CORNER_CENTRE_Z var end_run_full := 2.0 * CORNER_CENTRE_X var end_run_split := CORNER_CENTRE_X - GOAL_MOUTH_HALF_WIDTH var end_centre_x := GOAL_MOUTH_HALF_WIDTH + end_run_split / 2.0 var runs: Array[Dictionary] = [] for side in [-1.0, 1.0]: runs.append({ "wall_out": Vector3(side, 0, 0), "run_dir": Vector3(0, 0, 1), "centre": Vector3(side * FLAT_HALF_X, centre_y, 0), "length": side_run, "caps": [false, false], }) if not split: runs.append({ "wall_out": Vector3(0, 0, side), "run_dir": Vector3(1, 0, 0), "centre": Vector3(0, centre_y, side * FLAT_HALF_Z), "length": end_run_full, "caps": [false, false], }) continue for goal_side in [-1.0, 1.0]: runs.append({ "wall_out": Vector3(0, 0, side), "run_dir": Vector3(1, 0, 0), "centre": Vector3(goal_side * end_centre_x, centre_y, side * FLAT_HALF_Z), "length": end_run_split, # The end at the corner wrap is unexposed; the end at the goal # mouth needs a cap. run_dir is always +X, so the mouth-facing # end is -run_dir when goal_side is +1. "caps": [goal_side > 0.0, goal_side < 0.0], }) return runs # --- collision --------------------------------------------------------------- # These build the tangent-box collider rings only. Their geometry is what # trained policies in Game/bots/ were fitted against, so it must not change. # Vertical quarter-cylinder curves across the four wall-wall corners. func _build_corner_colliders() -> void: var arc_step := (PI / 2.0) / CORNER_SEGMENTS # Wide enough that adjacent tangent segments overlap instead of gapping. var face_width := 2.0 * CORNER_RADIUS * tan(arc_step / 2.0) + 0.4 for sx in [-1.0, 1.0]: for sz in [-1.0, 1.0]: var arc_centre := _corner_centre(sx, sz) for i in CORNER_SEGMENTS: # Angle sweeps the quarter arc from facing the ±x wall (0) # to facing the ±z wall (90°); segments are tangent at # their arc midpoints, so the ends sit flush on the walls. var angle := (i + 0.5) * arc_step var outward := Vector3(sx * cos(angle), 0.0, sz * sin(angle)) _add_curve_collider( arc_centre + outward * CORNER_RADIUS + Vector3.UP * WALL_CENTRE_Y, outward, Vector3.UP, face_width, WALL_HEIGHT ) # Quarter-cylinder fillets easing every wall into the floor (rise +1) or the # ceiling (rise -1), with torus sections wrapping each corner curve so the # side- and end-wall runs join with no exposed end face. func _build_fillet_colliders(rise: float) -> void: var arc_step := (PI / 2.0) / BASE_SEGMENTS var face_width := 2.0 * BASE_RADIUS * tan(arc_step / 2.0) + 0.3 for run in _fillet_runs(rise): var wall_out: Vector3 = run["wall_out"] var run_dir: Vector3 = run["run_dir"] var centre: Vector3 = run["centre"] var length: float = run["length"] for i in BASE_SEGMENTS: var outward := _fillet_dir(wall_out, (i + 0.5) * arc_step, rise) _add_curve_collider(centre + outward * BASE_RADIUS, outward, run_dir, face_width, length) for sx in [-1.0, 1.0]: for sz in [-1.0, 1.0]: _add_wrap_colliders(sx, sz, rise) # Torus section carrying a fillet around a corner curve. With u(phi) the # horizontal radial direction from the corner arc's centre, the surface is # P(phi, theta) = origin + u * (CORNER_RADIUS - BASE_RADIUS # + BASE_RADIUS * sin(theta)) # + UP * rise * BASE_RADIUS * (1 - cos(theta)) # whose outward (into-material) normal is # u * sin(theta) - UP * rise * cos(theta). # Note `rise` enters the position and the normal with opposite signs. func _add_wrap_colliders(sx: float, sz: float, rise: float) -> void: var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise) var path_step := (PI / 2.0) / WRAP_SEGMENTS var profile_step := (PI / 2.0) / BASE_SEGMENTS var face_width := 2.0 * BASE_RADIUS * tan(profile_step / 2.0) + 0.3 for i in WRAP_SEGMENTS: var phi := (i + 0.5) * path_step var u := Vector3(sx * cos(phi), 0.0, sz * sin(phi)) var along := Vector3(-sx * sin(phi), 0.0, sz * cos(phi)) for j in BASE_SEGMENTS: var theta := (j + 0.5) * profile_step var ring_radius := WRAP_RADIUS + BASE_RADIUS * sin(theta) var face_centre := origin + u * ring_radius \ + Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta))) var outward := u * sin(theta) - Vector3.UP * (rise * cos(theta)) var run_length := ring_radius * 2.0 * tan(path_step / 2.0) + 0.3 _add_curve_collider(face_centre, outward, along, face_width, run_length) # One flat tangent collider segment of a curved surface: a box whose inner # face is centred on face_centre, facing -outward, running run_length along # `along` and face_width across. func _add_curve_collider( face_centre: Vector3, outward: Vector3, along: Vector3, face_width: float, run_length: float ) -> void: var segment_basis := Basis(outward, along, outward.cross(along)) var origin := face_centre + outward * (SURFACE_THICKNESS / 2.0) var collision := CollisionShape3D.new() var box := BoxShape3D.new() box.size = Vector3(SURFACE_THICKNESS, run_length, face_width) collision.shape = box collision.transform = Transform3D(segment_basis, origin) add_child(collision) # --- visual shell ------------------------------------------------------------ # One mesh covering the inner surface of the play volume exactly once. Every # piece is cut to meet its neighbours edge-on, so no two translucent surfaces # overlap — overlapping alpha was what made the enclosure read as patches of # differing brightness. Only inward-facing triangles are emitted; the outer # faces and the 1 m overhang the old BoxMeshes carried simply don't exist. # # Two surfaces share the mesh: an opaque hull (deck, base fillets and the goal # surrounds) and the translucent containment field (walls, corners, ceiling # fillets, ceiling). func _build_visual_shell() -> void: var mesh := ArrayMesh.new() var deck := SurfaceTool.new() deck.begin(Mesh.PRIMITIVE_TRIANGLES) _add_flat_panel(deck, 0.0, Vector3.UP) _add_goal_aprons(deck) _add_goal_surrounds(deck) _add_fillet_surface(deck, BASE_FILLET) deck.commit(mesh) mesh.surface_set_material(0, _make_deck_material()) _field_material = _make_field_material() var field := SurfaceTool.new() field.begin(Mesh.PRIMITIVE_TRIANGLES) _add_wall_panels(field) _add_corner_panels(field) _add_fillet_surface(field, CEILING_FILLET) _add_flat_panel(field, INNER_HEIGHT, Vector3.DOWN) field.commit(mesh) mesh.surface_set_material(1, _field_material) _shell = MeshInstance3D.new() _shell.name = "SurfaceShell" _shell.mesh = mesh # Ships and the ball still cast onto the deck; the deck shadowing itself # would only produce acne. _shell.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF add_child(_shell) func _make_deck_material() -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = load(DECK_SHADER_PATH) # The markings are drawn from these, so they track the collision geometry. mat.set_shader_parameter("half_x", INNER_HALF_X) mat.set_shader_parameter("half_z", INNER_HALF_Z) mat.set_shader_parameter("goal_line_z", GOAL_LINE_Z) mat.set_shader_parameter("base_radius", BASE_RADIUS) mat.set_shader_parameter("team0_color", team0_tint) mat.set_shader_parameter("team1_color", team1_tint) mat.set_shader_parameter("seam_color", field_tint) return mat func _make_field_material() -> ShaderMaterial: var mat := ShaderMaterial.new() mat.shader = load(FIELD_SHADER_PATH) mat.set_shader_parameter("field_color", field_tint) mat.set_shader_parameter("field_intensity", field_intensity) return mat # The flat deck (or ceiling): a rounded rectangle whose straight edges stop on # the fillet tangents and whose corners follow the corner wraps' inner arc, so # it meets the fillets exactly once. Decomposed into a centre span, two end # spans narrowed to clear the corner arcs, and four quarter-discs — a partition # with no overlaps. func _add_flat_panel(st: SurfaceTool, y: float, face: Vector3) -> void: var origin := Vector3(0, y, 0) var right := Vector3(1, 0, 0) var forward := Vector3(0, 0, 1) _add_plane_quad(st, origin, right, forward, face, -FLAT_HALF_X, FLAT_HALF_X, -CORNER_CENTRE_Z, CORNER_CENTRE_Z) for sz in [-1.0, 1.0]: var z0: float = sz * CORNER_CENTRE_Z var z1: float = sz * FLAT_HALF_Z _add_plane_quad(st, origin, right, forward, face, -CORNER_CENTRE_X, CORNER_CENTRE_X, minf(z0, z1), maxf(z0, z1)) var step := (PI / 2.0) / FILLET_VISUAL_ARCS for sx in [-1.0, 1.0]: for sz in [-1.0, 1.0]: var centre := _corner_centre(sx, sz) + Vector3.UP * y for i in FILLET_VISUAL_ARCS: var d0 := Vector3(sx * cos(i * step), 0.0, sz * sin(i * step)) var d1 := Vector3(sx * cos((i + 1) * step), 0.0, sz * sin((i + 1) * step)) _add_cap_tri(st, centre, centre + d0 * WRAP_RADIUS, centre + d1 * WRAP_RADIUS, face) # In FLOOR mode the end-wall fillet stops short of the goal mouth, so the deck # has to run flat all the way to the end wall across that gap — otherwise there # would be a hole in front of each goal. func _add_goal_aprons(st: SurfaceTool) -> void: if goal_mode != GoalMode.FLOOR: return for sz in [-1.0, 1.0]: var z0: float = sz * FLAT_HALF_Z var z1: float = sz * INNER_HALF_Z _add_plane_quad(st, Vector3.ZERO, Vector3(1, 0, 0), Vector3(0, 0, 1), Vector3.UP, -GOAL_MOUTH_HALF_WIDTH, GOAL_MOUTH_HALF_WIDTH, minf(z0, z1), maxf(z0, z1)) # Opaque bulkhead around each goal mouth, with the aperture cut out of it. This # is the solid hull the goal recess is set into: left as translucent field # panel, the recess and net behind it showed through the wall and the net read # as duplicated either side of the frame. func _add_goal_surrounds(st: SurfaceTool) -> void: var bounds := _goal_surround_bounds() for side in [-1.0, 1.0]: _add_aperture_panel(st, Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP, Vector3(0, 0, -side), GOAL_MOUTH_HALF_WIDTH, bounds.x, bounds.y, GOAL_APERTURE_HALF_WIDTH, bounds.z, bounds.w) # (surround bottom, surround top, aperture bottom, aperture top) on the end # wall. In FLOOR mode the surround spans deck to fillet tangent with the goal # sitting on the deck; in ELEVATED it is a band centred on the raised goal. func _goal_surround_bounds() -> Vector4: if goal_mode == GoalMode.FLOOR: return Vector4(0.0, BASE_RADIUS, 0.0, GOAL_APERTURE_HEIGHT) var half := GOAL_APERTURE_HEIGHT / 2.0 return Vector4( GOAL_CENTER_Y - GOAL_SURROUND_HALF_HEIGHT, GOAL_CENTER_Y + GOAL_SURROUND_HALF_HEIGHT, GOAL_CENTER_Y - half, GOAL_CENTER_Y + half) func _add_fillet_surface(st: SurfaceTool, rise: float) -> void: for run in _fillet_runs(rise): var caps: Array = run["caps"] _add_fillet_visual(st, run["wall_out"], run["run_dir"], run["centre"], run["length"], caps[0], caps[1], rise) for sx in [-1.0, 1.0]: for sz in [-1.0, 1.0]: _add_wrap_visual(st, sx, sz, rise) # Wall panels, spanning between the fillets vertically and between the corner # curves horizontally. Each end wall is cut around the goal surround, which is # opaque hull carrying the aperture itself (see _add_goal_surrounds). In FLOOR # mode that surround sits below the panels entirely, so only the ELEVATED one # punches a hole through them. func _add_wall_panels(st: SurfaceTool) -> void: var y0 := BASE_RADIUS var y1 := INNER_HEIGHT - BASE_RADIUS var elevated := goal_mode == GoalMode.ELEVATED var bounds := _goal_surround_bounds() for side in [-1.0, 1.0]: _add_aperture_panel(st, Vector3(side * INNER_HALF_X, 0, 0), Vector3(0, 0, side), Vector3.UP, Vector3(-side, 0, 0), CORNER_CENTRE_Z, y0, y1, 0.0, 0.0, 0.0) _add_aperture_panel(st, Vector3(0, 0, side * INNER_HALF_Z), Vector3(-side, 0, 0), Vector3.UP, Vector3(0, 0, -side), CORNER_CENTRE_X, y0, y1, GOAL_MOUTH_HALF_WIDTH if elevated else 0.0, bounds.x, bounds.y) # The corner quarter-cylinders, trimmed to the wall panels' height range so # they abut the fillet wraps instead of running through them. func _add_corner_panels(st: SurfaceTool) -> void: var y0 := BASE_RADIUS var y1 := INNER_HEIGHT - BASE_RADIUS var arc_step := (PI / 2.0) / CORNER_VISUAL_ARCS for sx in [-1.0, 1.0]: for sz in [-1.0, 1.0]: var arc_centre := _corner_centre(sx, sz) for i in CORNER_VISUAL_ARCS: var d0 := Vector3(sx * cos(i * arc_step), 0.0, sz * sin(i * arc_step)) var d1 := Vector3(sx * cos((i + 1) * arc_step), 0.0, sz * sin((i + 1) * arc_step)) var b0 := arc_centre + d0 * CORNER_RADIUS var b1 := arc_centre + d1 * CORNER_RADIUS _add_quad(st, b0 + Vector3.UP * y0, -d0, b1 + Vector3.UP * y0, -d1, b1 + Vector3.UP * y1, -d1, b0 + Vector3.UP * y1, -d0) # One smooth fillet strip, optionally capped at either end (an end is capped # when it stops at the goal mouth rather than meeting a corner wrap). func _add_fillet_visual( st: SurfaceTool, wall_out: Vector3, run_dir: Vector3, centre: Vector3, length: float, cap_start: bool, cap_end: bool, rise: float ) -> void: var arc_step := (PI / 2.0) / FILLET_VISUAL_ARCS var end_a := centre - run_dir * (length / 2.0) var end_b := centre + run_dir * (length / 2.0) for i in FILLET_VISUAL_ARCS: var dir_a := _fillet_dir(wall_out, i * arc_step, rise) var dir_b := _fillet_dir(wall_out, (i + 1) * arc_step, rise) _add_quad( st, end_a + dir_a * BASE_RADIUS, -dir_a, end_b + dir_a * BASE_RADIUS, -dir_a, end_b + dir_b * BASE_RADIUS, -dir_b, end_a + dir_b * BASE_RADIUS, -dir_b ) var caps: Array[Array] = [] if cap_start: caps.append([end_a, -run_dir]) if cap_end: caps.append([end_b, run_dir]) for cap in caps: var end_point: Vector3 = cap[0] var cap_normal: Vector3 = cap[1] # Fan from the wall-floor corner of the cross-section round to the arc. var cap_corner := end_point + (wall_out + _fillet_dir(wall_out, 0.0, rise)) * BASE_RADIUS for i in FILLET_VISUAL_ARCS: var p0 := end_point + _fillet_dir(wall_out, i * arc_step, rise) * BASE_RADIUS var p1 := end_point + _fillet_dir(wall_out, (i + 1) * arc_step, rise) * BASE_RADIUS _add_cap_tri(st, cap_corner, p0, p1, cap_normal) # Smooth torus patch over the same sweep _add_wrap_colliders describes. func _add_wrap_visual(st: SurfaceTool, sx: float, sz: float, rise: float) -> void: var origin := _corner_centre(sx, sz) + Vector3.UP * _fillet_base_y(rise) var path_step := (PI / 2.0) / WRAP_VISUAL_ARCS var profile_step := (PI / 2.0) / FILLET_VISUAL_ARCS for i in WRAP_VISUAL_ARCS: for j in FILLET_VISUAL_ARCS: var points: Array[Vector3] = [] var normals: Array[Vector3] = [] for corner in [[i, j], [i + 1, j], [i + 1, j + 1], [i, j + 1]]: var phi: float = corner[0] * path_step var theta: float = corner[1] * profile_step var u := Vector3(sx * cos(phi), 0.0, sz * sin(phi)) points.append(origin + u * (WRAP_RADIUS + BASE_RADIUS * sin(theta)) + Vector3.UP * (rise * BASE_RADIUS * (1.0 - cos(theta)))) normals.append(-(u * sin(theta) - Vector3.UP * (rise * cos(theta)))) _add_quad( st, points[0], normals[0], points[1], normals[1], points[2], normals[2], points[3], normals[3] ) # --- meshing primitives ------------------------------------------------------ # A flat rectangle in the plane through `plane_origin` spanned by `right`/`up`, # with an optional rectangular aperture cut out of it (hole_half_width <= 0 for # none). Decomposed into the up-to-four quads surrounding the hole so the panel # stays a single non-overlapping layer. func _add_aperture_panel( st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3, normal: Vector3, half_width: float, v0: float, v1: float, hole_half_width: float, hole_v0: float, hole_v1: float ) -> void: if hole_half_width <= 0.0: _add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, v1) return var lo: float = clampf(hole_v0, v0, v1) var hi: float = clampf(hole_v1, v0, v1) _add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, v0, lo) _add_plane_quad(st, plane_origin, right, up, normal, -half_width, half_width, hi, v1) _add_plane_quad(st, plane_origin, right, up, normal, -half_width, -hole_half_width, lo, hi) _add_plane_quad(st, plane_origin, right, up, normal, hole_half_width, half_width, lo, hi) # One quad in the plane through `plane_origin` spanned by `right`/`up`, over # the given parameter ranges. Degenerate spans are skipped so callers can pass # empty slices (an aperture flush with a panel edge, say) without guarding. func _add_plane_quad( st: SurfaceTool, plane_origin: Vector3, right: Vector3, up: Vector3, normal: Vector3, u0: float, u1: float, v0: float, v1: float ) -> void: if u1 - u0 <= 0.0001 or v1 - v0 <= 0.0001: return _add_quad(st, plane_origin + right * u0 + up * v0, normal, plane_origin + right * u1 + up * v0, normal, plane_origin + right * u1 + up * v1, normal, plane_origin + right * u0 + up * v1, normal) # Quad a-b-c-d with per-vertex normals, wound so the front faces the normals # (Godot front faces wind clockwise when seen from the normal side). func _add_quad( st: SurfaceTool, a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3, d: Vector3, nd: Vector3 ) -> void: if (b - a).cross(c - a).dot(na + nb + nc + nd) < 0.0: _add_tri(st, a, na, b, nb, c, nc) _add_tri(st, a, na, c, nc, d, nd) else: _add_tri(st, a, na, d, nd, c, nc) _add_tri(st, a, na, c, nc, b, nb) func _add_cap_tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3, normal: Vector3) -> void: if (b - a).cross(c - a).dot(normal) < 0.0: _add_tri(st, a, normal, b, normal, c, normal) else: _add_tri(st, a, normal, c, normal, b, normal) func _add_tri( st: SurfaceTool, a: Vector3, na: Vector3, b: Vector3, nb: Vector3, c: Vector3, nc: Vector3 ) -> void: st.set_normal(na) st.add_vertex(a) st.set_normal(nb) st.add_vertex(b) st.set_normal(nc) st.add_vertex(c)