diff --git a/src/godotiq/tools/spatial/__init__.py b/src/godotiq/tools/spatial/__init__.py
index 5ab7635..56e8bde 100644
--- a/src/godotiq/tools/spatial/__init__.py
+++ b/src/godotiq/tools/spatial/__init__.py
@@ -383,6 +383,7 @@ _Z_FIGHTING_DISTANCE = 0.01
 _EXTREME_DISTANCE = 1000.0
 _OVERLAP_DISTANCE = 0.01
 _MAX_AUDIT_ISSUES = 50
+_SEVERITY_ORDER: dict[str, int] = {"critical": 0, "warning": 1, "info": 2}
 
 ALL_CHECKS = [
     "floating_objects",
@@ -466,6 +467,16 @@ def _is_mesh_geometry(wn: WorldNode) -> bool:
     return False
 
 
+def _parent_path(full_path: str) -> str:
+    """Extract parent path from full_path (e.g., 'A/B/C' -> 'A/B').
+
+    Root-level nodes (no '/' in path) return empty string.
+    """
+    if "/" in full_path:
+        return full_path.rsplit("/", 1)[0]
+    return ""
+
+
 def _check_z_fighting(world_nodes: list[WorldNode]) -> list[dict]:
     """Detect mesh-geometry nodes at nearly identical positions."""
     # Filter to mesh-only nodes — Node3D/Marker3D containers at origin are not geometry
@@ -480,12 +491,13 @@ def _check_z_fighting(world_nodes: list[WorldNode]) -> list[dict]:
             dist = _compute_distance(a.world_position, b.world_position)
             if dist < _Z_FIGHTING_DISTANCE and a.node.name != b.node.name:
                 seen.add(pair_key)
+                severity = "info" if _parent_path(a.full_path) == _parent_path(b.full_path) else "warning"
                 issues.append({
                     "check": "z_fighting",
                     "node": a.node.name,
                     "other_node": b.node.name,
                     "position": list(a.world_position),
-                    "severity": "warning",
+                    "severity": severity,
                     "detail": f"Distance {dist:.4f} to {b.node.name}",
                     "suggestion": "Offset nodes slightly to avoid z-fighting",
                 })
@@ -556,6 +568,7 @@ def _build_spatial_audit(
     session: GodotIQSession,
     scene: str,
     checks: list[str] | None = None,
+    min_severity: str = "warning",
 ) -> dict:
     """Run spatial audit checks on a scene.
 
@@ -563,10 +576,14 @@ def _build_spatial_audit(
         session: Loaded GodotIQSession.
         scene: res:// path to .tscn file.
         checks: Optional list of check names to run. Runs all if None.
+        min_severity: Minimum severity to include in results. Default "warning"
+            filters out info-level issues. Use "info" to see everything.
 
     Returns:
         Dictionary with audit results.
     """
+    if min_severity not in _SEVERITY_ORDER:
+        min_severity = "warning"
     spatial = session.resolve_scene_spatial(scene)
     if spatial is None:
         return {"error": "Scene not found", "scene": scene}
@@ -594,9 +611,18 @@ def _build_spatial_audit(
             all_issues.extend(found)
             by_check[check_name] = len(found)
 
-    # Sort: warnings before info
-    severity_order = {"warning": 0, "info": 1}
-    all_issues.sort(key=lambda i: severity_order.get(i["severity"], 99))
+    # Compute by_severity counts before filtering
+    by_severity: dict[str, int] = {}
+    for issue in all_issues:
+        sev = issue["severity"]
+        by_severity[sev] = by_severity.get(sev, 0) + 1
+
+    # Sort: higher severity first
+    all_issues.sort(key=lambda i: _SEVERITY_ORDER.get(i["severity"], 99))
+
+    # Filter by min_severity
+    threshold = _SEVERITY_ORDER[min_severity]
+    all_issues = [i for i in all_issues if _SEVERITY_ORDER.get(i["severity"], 99) <= threshold]
 
     # Cap at max
     all_issues = all_issues[:_MAX_AUDIT_ISSUES]
@@ -604,6 +630,9 @@ def _build_spatial_audit(
     return {
         "scene": scene,
         "total_issues": sum(by_check.values()),
+        "shown_issues": len(all_issues),
+        "filtered_by": min_severity,
+        "by_severity": by_severity,
         "issues": all_issues,
         "by_check": by_check,
         "checks_run": list(checks_to_run),
@@ -622,6 +651,7 @@ def _build_spatial_audit(
 async def godotiq_spatial_audit(
     scene: str,
     checks: list[str] | None = None,
+    min_severity: str = "warning",
     ctx: Context = None,
 ) -> dict:
     """Spatial linter for 3D scenes — detects floating objects, scale mismatches, z-fighting, and more.
@@ -629,8 +659,10 @@ async def godotiq_spatial_audit(
     Args:
         scene: Path to .tscn file (res:// or relative).
         checks: Optional list of specific checks to run. Runs all 6 if omitted.
+        min_severity: Minimum severity to include in results. Default "warning"
+            filters out info-level issues. Use "info" to see everything.
     """
     session = ctx.request_context.lifespan_context["session"]
     if session is None:
         return {"error": "No Godot project loaded"}
-    return _build_spatial_audit(session, scene, checks)
+    return _build_spatial_audit(session, scene, checks, min_severity=min_severity)
diff --git a/tests/test_tools/test_spatial_audit.py b/tests/test_tools/test_spatial_audit.py
new file mode 100644
index 0000000..1487130
--- /dev/null
+++ b/tests/test_tools/test_spatial_audit.py
@@ -0,0 +1,436 @@
+"""Tests for godotiq_spatial_audit tool.
+
+Sprint 3c RED phase: These tests define expected behavior for the spatial audit
+tool and will fail until the implementation is complete.
+
+Design Doc: Sprint 3c — Spatial Audit
+Generated: 2026-03-03 | Budget Used: 11/11 integration, 0/2 E2E
+"""
+
+from __future__ import annotations
+
+from godotiq.session import GodotIQSession
+from godotiq.tools.spatial import _build_spatial_audit
+
+
+class TestSpatialAuditAllChecks:
+    # AC: "Spatial audit runs all checks by default and returns aggregate results"
+    # Behavior: Call audit with no check filter -> returns total_issues count and by_check breakdown
+    # @category: core-functionality
+    # @dependency: GodotIQSession, scene resolver
+    # @complexity: medium
+    # ROI: 90 | Business Value: 9 (core tool output) | Frequency: 10 (every audit call)
+    def test_audit_all_checks(self, session: GodotIQSession) -> None:
+        """All checks run by default; result has total_issues (int) and by_check (dict)."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: result must contain total_issues as an integer
+        assert "total_issues" in result
+        assert isinstance(result["total_issues"], int)
+        # SKELETON: result must contain by_check dict mapping check names to issue counts
+        assert "by_check" in result
+        assert isinstance(result["by_check"], dict)
+        # SKELETON: by_check keys should include known check names
+        expected_checks = {
+            "floating_objects",
+            "scale_mismatch",
+            "z_fighting",
+            "empty_markers",
+            "overlapping",
+            "extreme_positions",
+        }
+        assert expected_checks <= set(result["by_check"].keys())
+
+
+class TestSpatialAuditFloatingObjects:
+    # AC: "Detect nodes with high Y coordinates that are not lights or cameras"
+    # Behavior: Scene with node at high Y -> flagged as floating_objects issue
+    # @category: core-functionality
+    # @dependency: GodotIQSession, scene resolver, world transforms
+    # @complexity: medium
+    # ROI: 82 | Business Value: 8 | Frequency: 7
+    def test_audit_floating_objects(self, session: GodotIQSession) -> None:
+        """Detects nodes with high Y that are not lights or cameras."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: issues list should contain entries where check == "floating_objects"
+        # for any node with Y position significantly above scene center that is
+        # not a light (OmniLight3D, SpotLight3D, DirectionalLight3D) or Camera3D
+        assert "issues" in result
+        floating = [i for i in result["issues"] if i["check"] == "floating_objects"]
+        # SKELETON: verify each floating issue has node_name, position, and severity
+        for issue in floating:
+            assert "node" in issue
+            assert "position" in issue
+            assert "severity" in issue
+
+
+class TestSpatialAuditScaleMismatch:
+    # AC: "Detect nodes with extreme scale relative to siblings"
+    # Behavior: Node with scale much larger/smaller than siblings -> scale_mismatch
+    # @category: core-functionality
+    # @dependency: GodotIQSession, world transforms
+    # @complexity: medium
+    # ROI: 78 | Business Value: 8 | Frequency: 6
+    def test_audit_scale_mismatch(self, session: GodotIQSession) -> None:
+        """Detects nodes with extreme scale vs siblings."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # Sibling scales: PrinterModel 0.35, FilamentSpool 0.15, NozzleMesh 0.2,
+        # WorkPoint 1.0, StatusLight 1.0. Median ~0.35 → WorkPoint/StatusLight are outliers.
+        assert "issues" in result
+        scale_issues = [i for i in result["issues"] if i["check"] == "scale_mismatch"]
+        assert len(scale_issues) >= 1
+        scale_nodes = [i["node"] for i in scale_issues]
+        # WorkPoint and StatusLight at 1.0 deviate from the median
+        assert "WorkPoint" in scale_nodes or "StatusLight" in scale_nodes
+
+
+class TestSpatialAuditZFighting:
+    # AC: "Detect nodes at the same Y/XZ position that may cause z-fighting"
+    # Behavior: Two nodes at overlapping positions -> z_fighting issue
+    # @category: core-functionality
+    # @dependency: GodotIQSession, world transforms
+    # @complexity: medium
+    # ROI: 72 | Business Value: 7 | Frequency: 5
+    def test_audit_z_fighting(self, session: GodotIQSession) -> None:
+        """Detects nodes at same Y/XZ position (potential z-fighting)."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: z_fighting check should look for pairs of nodes at nearly
+        # identical positions; verify issue structure when present
+        assert "issues" in result
+        zf_issues = [i for i in result["issues"] if i["check"] == "z_fighting"]
+        for issue in zf_issues:
+            assert "node" in issue
+            assert "severity" in issue
+
+
+class TestSpatialAuditEmptyMarkers:
+    # AC: "Detect Marker3D nodes with groups but no children"
+    # Behavior: Marker3D with group assignment but zero children -> empty_markers issue
+    # @category: edge-case
+    # @dependency: GodotIQSession, scene resolver
+    # @complexity: low
+    # ROI: 60 | Business Value: 5 | Frequency: 4
+    def test_audit_empty_markers(self, session: GodotIQSession) -> None:
+        """Detects Marker3D with groups but no children."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: any Marker3D node that has groups but no children should
+        # be flagged; verify the check runs even if no markers in fixture
+        assert "issues" in result
+        marker_issues = [i for i in result["issues"] if i["check"] == "empty_markers"]
+        for issue in marker_issues:
+            assert "node" in issue
+            assert "severity" in issue
+
+
+class TestSpatialAuditOverlapping:
+    # AC: "Detect instance roots placed at the same position"
+    # Behavior: Two instance roots at identical positions -> overlapping issue
+    # @category: core-functionality
+    # @dependency: GodotIQSession, scene resolver, world transforms
+    # @complexity: medium
+    # ROI: 70 | Business Value: 7 | Frequency: 5
+    def test_audit_overlapping(self, session: GodotIQSession) -> None:
+        """Detects instance roots at same position."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: overlapping check should examine instance root nodes;
+        # verify issue structure when present
+        assert "issues" in result
+        overlap = [i for i in result["issues"] if i["check"] == "overlapping"]
+        for issue in overlap:
+            assert "node" in issue
+            assert "severity" in issue
+
+
+class TestSpatialAuditExtremePositions:
+    # AC: "Detect nodes positioned far from the scene center"
+    # Behavior: Node at extreme distance from scene centroid -> extreme_positions issue
+    # @category: edge-case
+    # @dependency: GodotIQSession, world transforms
+    # @complexity: low
+    # ROI: 65 | Business Value: 6 | Frequency: 4
+    def test_audit_extreme_positions(self, session: GodotIQSession) -> None:
+        """Detects nodes far from scene center."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: extreme_positions check uses distance from scene centroid;
+        # verify issue structure when present
+        assert "issues" in result
+        extreme = [i for i in result["issues"] if i["check"] == "extreme_positions"]
+        for issue in extreme:
+            assert "node" in issue
+            assert "position" in issue
+            assert "severity" in issue
+
+
+class TestSpatialAuditSeverityOrder:
+    # AC: "Issues are sorted by severity: warnings before info"
+    # Behavior: Result issues list is ordered warning-first, then info
+    # @category: integration
+    # @dependency: GodotIQSession
+    # @complexity: low
+    # ROI: 68 | Business Value: 6 | Frequency: 8
+    def test_audit_severity_order(self, session: GodotIQSession) -> None:
+        """Warnings appear before info in the issues list."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        assert "issues" in result
+        severity_order = {"warning": 0, "info": 1}
+        issues = result["issues"]
+        for i in range(len(issues) - 1):
+            current = severity_order.get(issues[i]["severity"], 99)
+            next_val = severity_order.get(issues[i + 1]["severity"], 99)
+            assert current <= next_val
+
+
+class TestSpatialAuditCap:
+    # AC: "Maximum 50 issues returned to prevent token bloat"
+    # Behavior: Even with many issues, result is capped at 50
+    # @category: edge-case
+    # @dependency: GodotIQSession
+    # @complexity: low
+    # ROI: 55 | Business Value: 5 | Frequency: 3
+    def test_audit_cap(self, session: GodotIQSession) -> None:
+        """Max 50 issues returned."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn"
+        )
+        # SKELETON: regardless of how many issues exist, the issues list
+        # must be capped at 50 entries
+        assert "issues" in result
+        assert len(result["issues"]) <= 50
+
+
+class TestSpatialAuditSpecificChecks:
+    # AC: "Passing checks parameter runs only the specified checks"
+    # Behavior: checks=["floating_objects"] -> only floating_objects issues returned
+    # @category: core-functionality
+    # @dependency: GodotIQSession
+    # @complexity: low
+    # ROI: 75 | Business Value: 7 | Frequency: 6
+    def test_audit_specific_checks(self, session: GodotIQSession) -> None:
+        """Passing checks=['floating_objects'] only runs that check."""
+        result = _build_spatial_audit(
+            session,
+            "res://scenes/equipment/fdm_printer.tscn",
+            checks=["floating_objects"],
+        )
+        # SKELETON: only floating_objects issues should appear
+        assert "issues" in result
+        for issue in result["issues"]:
+            assert issue["check"] == "floating_objects"
+        # SKELETON: by_check should only contain the requested check
+        assert "by_check" in result
+        assert set(result["by_check"].keys()) == {"floating_objects"}
+
+
+class TestSpatialAuditZFightingMeshOnly:
+    # AC: "z_fighting only considers mesh geometry nodes, not organizational containers"
+    # Behavior: Node3D/Marker3D at (0,0,0) are containers, not z-fighting candidates
+    # @category: bugfix
+    # @dependency: GodotIQSession, scene resolver
+    # @complexity: medium
+    # ROI: 85 | Business Value: 9 | Frequency: 8
+    def test_z_fighting_excludes_non_mesh(self) -> None:
+        """z_fighting check excludes pure Node3D/Marker3D containers at origin."""
+        from godotiq.parsers.tscn_parser import TscnNode
+        from godotiq.parsers.scene_resolver import WorldNode
+        from godotiq.tools.spatial import _check_z_fighting
+
+        # Two Node3D containers at the same position (0,0,0) — organizational, not mesh
+        container_a = WorldNode(
+            node=TscnNode(name="ContainerA", type="Node3D"),
+            world_position=(0.0, 0.0, 0.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="ContainerA",
+            depth=1,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        container_b = WorldNode(
+            node=TscnNode(name="ContainerB", type="Marker3D"),
+            world_position=(0.0, 0.0, 0.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="ContainerB",
+            depth=1,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        # A mesh node at the same position
+        mesh_a = WorldNode(
+            node=TscnNode(name="MeshA", type="MeshInstance3D"),
+            world_position=(0.0, 0.0, 0.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="MeshA",
+            depth=1,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        # A .glb instance at the same position
+        mesh_b = WorldNode(
+            node=TscnNode(name="ModelB", type=""),
+            world_position=(0.0, 0.0, 0.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="ModelB",
+            depth=1,
+            is_instance_root=True,
+            instance_source="res://assets/models/thing.glb",
+        )
+
+        nodes = [container_a, container_b, mesh_a, mesh_b]
+        issues = _check_z_fighting(nodes)
+
+        # Node3D+Marker3D pair at same position: should NOT be flagged (containers)
+        flagged_pairs = {(i["node"], i["other_node"]) for i in issues}
+        assert ("ContainerA", "ContainerB") not in flagged_pairs
+        assert ("ContainerB", "ContainerA") not in flagged_pairs
+
+        # MeshInstance3D + .glb model at same position: SHOULD be flagged
+        assert ("MeshA", "ModelB") in flagged_pairs or ("ModelB", "MeshA") in flagged_pairs
+
+
+class TestSpatialAuditMissingScene:
+    # AC: "Non-existent scene returns error dict"
+    # Behavior: Audit on missing path -> {"error": ...}
+    # @category: edge-case
+    # @dependency: GodotIQSession
+    # @complexity: low
+    # ROI: 70 | Business Value: 6 | Frequency: 3
+    def test_audit_missing_scene(self, session: GodotIQSession) -> None:
+        """Returns error dict for non-existent scene."""
+        result = _build_spatial_audit(session, "res://nonexistent.tscn")
+        assert "error" in result
+
+
+class TestSpatialAuditSiblingZFightingDowngraded:
+    def test_sibling_z_fighting_downgraded(self) -> None:
+        """Two mesh nodes under the same parent at identical positions → severity 'info'."""
+        from godotiq.parsers.tscn_parser import TscnNode
+        from godotiq.parsers.scene_resolver import WorldNode
+        from godotiq.tools.spatial import _check_z_fighting
+
+        node_a = WorldNode(
+            node=TscnNode(name="TileA", type="MeshInstance3D"),
+            world_position=(1.0, 0.0, 1.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="Room/Floor/TileA",
+            depth=3,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        node_b = WorldNode(
+            node=TscnNode(name="TileB", type="MeshInstance3D"),
+            world_position=(1.0, 0.0, 1.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="Room/Floor/TileB",
+            depth=3,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        issues = _check_z_fighting([node_a, node_b])
+        assert len(issues) == 1
+        assert issues[0]["severity"] == "info"
+        assert issues[0]["check"] == "z_fighting"
+
+    def test_root_level_siblings_downgraded(self) -> None:
+        """Root-level siblings (no '/' in path) also downgraded to info."""
+        from godotiq.parsers.tscn_parser import TscnNode
+        from godotiq.parsers.scene_resolver import WorldNode
+        from godotiq.tools.spatial import _check_z_fighting
+
+        node_a = WorldNode(
+            node=TscnNode(name="TileA", type="MeshInstance3D"),
+            world_position=(0.0, 0.0, 0.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="TileA",
+            depth=1,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        node_b = WorldNode(
+            node=TscnNode(name="TileB", type="MeshInstance3D"),
+            world_position=(0.0, 0.0, 0.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="TileB",
+            depth=1,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        issues = _check_z_fighting([node_a, node_b])
+        assert len(issues) == 1
+        assert issues[0]["severity"] == "info"
+
+
+class TestSpatialAuditNonSiblingZFightingWarning:
+    def test_non_sibling_z_fighting_stays_warning(self) -> None:
+        """Two mesh nodes under different parents at identical positions → severity 'warning'."""
+        from godotiq.parsers.tscn_parser import TscnNode
+        from godotiq.parsers.scene_resolver import WorldNode
+        from godotiq.tools.spatial import _check_z_fighting
+
+        node_a = WorldNode(
+            node=TscnNode(name="TileA", type="MeshInstance3D"),
+            world_position=(1.0, 0.0, 1.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="Room/Floor/TileA",
+            depth=3,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        node_b = WorldNode(
+            node=TscnNode(name="RugB", type="MeshInstance3D"),
+            world_position=(1.0, 0.0, 1.0),
+            world_rotation=(0.0, 0.0, 0.0),
+            world_scale=(1.0, 1.0, 1.0),
+            full_path="Room/Rugs/RugB",
+            depth=3,
+            is_instance_root=False,
+            instance_source=None,
+        )
+        issues = _check_z_fighting([node_a, node_b])
+        assert len(issues) == 1
+        assert issues[0]["severity"] == "warning"
+        assert issues[0]["check"] == "z_fighting"
+
+
+class TestSpatialAuditMinSeverityFilter:
+    def test_min_severity_filter(self, session: GodotIQSession) -> None:
+        """min_severity='warning' filters out info issues but shows them in counts."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn", min_severity="warning"
+        )
+        # No info issues in the issues list
+        for issue in result["issues"]:
+            assert issue["severity"] != "info"
+        # by_severity dict present
+        assert isinstance(result["by_severity"], dict)
+        # shown_issues <= total_issues
+        assert result["shown_issues"] <= result["total_issues"]
+
+    def test_min_severity_info_shows_all(self, session: GodotIQSession) -> None:
+        """min_severity='info' shows everything."""
+        result = _build_spatial_audit(
+            session, "res://scenes/equipment/fdm_printer.tscn", min_severity="info"
+        )
+        assert result["shown_issues"] == result["total_issues"]
