class_name TerrainUtils extends RefCounted ## TerrainUtils - Static geometry engine for vector terrain (Phase 3). ## ## Two responsibilities: sanitize raw input points into a clean, grid-snapped, ## clockwise-ordered polygon; and spawn a configured TerrainBlock into a target ## container. Consumed by the standalone physics test harness; not referenced by ## the editor. # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const DEFAULT_GRID_SIZE: float = 16.0 const DEFAULT_FILL_COLOR := Color(0.25, 0.55, 0.25, 1.0) const DEFAULT_OUTLINE_COLOR := Color(0.05, 0.10, 0.05, 1.0) const DEFAULT_OUTLINE_WIDTH: float = 2.0 # --------------------------------------------------------------------------- # Point sanitization # --------------------------------------------------------------------------- ## Snap raw points to a grid, drop redundant nodes, and enforce clockwise ## winding (required for solid collision decomposition). Returns a new array; ## the input is not modified. static func sanitize_points(points: PackedVector2Array, grid_size: float = DEFAULT_GRID_SIZE) -> PackedVector2Array: var cleaned := PackedVector2Array() for p: Vector2 in points: cleaned.append(_snap_to_grid(p, grid_size)) cleaned = _simplify_polyline(cleaned) if cleaned.size() >= 3 and not Geometry2D.is_polygon_clockwise(cleaned): cleaned.reverse() return cleaned # --------------------------------------------------------------------------- # Factory spawner # --------------------------------------------------------------------------- ## Sanitize `points`, create a new TerrainBlock, apply the cleaned points and ## styling, and add it to `container`. Returns the spawned block. static func spawn_block( container: Node, points: PackedVector2Array, grid_size: float = DEFAULT_GRID_SIZE, fill_color: Color = DEFAULT_FILL_COLOR, outline_color: Color = DEFAULT_OUTLINE_COLOR, outline_width: float = DEFAULT_OUTLINE_WIDTH ) -> TerrainBlock: var block := TerrainBlock.new() block.name = "TerrainBlock" block.polygon_points = sanitize_points(points, grid_size) block.fill_color = fill_color block.outline_color = outline_color block.outline_width = outline_width container.add_child(block) return block # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- static func _snap_to_grid(p: Vector2, grid_size: float) -> Vector2: if grid_size <= 0.0: return p return Vector2(roundf(p.x / grid_size) * grid_size, roundf(p.y / grid_size) * grid_size) ## Remove redundant nodes: consecutive duplicates (e.g. collapsed by grid ## snapping) and collinear middle vertices (redundant for both render and ## collision). NOTE: Geometry2D.simplify_polyline() does not exist in Godot ## 4.7.1, so this local pass stands in for it. static func _simplify_polyline(points: PackedVector2Array) -> PackedVector2Array: var deduped := PackedVector2Array() for p: Vector2 in points: if not deduped.is_empty() and deduped[deduped.size() - 1].is_equal_approx(p): continue deduped.append(p) # Drop a closing duplicate (last vertex == first vertex). Polygons are # treated as open here; otherwise the collinear pass below would treat the # first/last vertices as "collinear" with each other and erase the first # corner, producing a degenerate polygon. if deduped.size() >= 2 and deduped[0].is_equal_approx(deduped[deduped.size() - 1]): deduped.remove_at(deduped.size() - 1) if deduped.size() < 3: return deduped var simplified := PackedVector2Array() var n: int = deduped.size() for i: int in n: var prev := deduped[(i - 1 + n) % n] var curr := deduped[i] var next := deduped[(i + 1) % n] if _is_collinear(prev, curr, next): continue simplified.append(curr) return simplified if simplified.size() >= 3 else deduped static func _is_collinear(a: Vector2, b: Vector2, c: Vector2) -> bool: return absf((b - a).cross(c - b)) < 0.001