@@ -245,8 +245,10 @@ def find_path(
245245
246246 if current == goal_node :
247247 path = _jps_reconstruct (came_from , current , terrain , near_z )
248- path = _simplify_path (path , terrain )
249- path = vary_path (path , terrain , jitter_range = jitter )
248+ path = _simplify_path (path , terrain , wb_override = wb , wbc_override = wbc )
249+ path = vary_path (
250+ path , terrain , jitter_range = jitter , near_z = near_z , wb_override = wb , wbc_override = wbc
251+ )
250252 _log_path_found (path , terrain , t0 , explored , start .x , start .y , goal .x , goal .y )
251253 return path
252254
@@ -468,8 +470,10 @@ def _find_path_astar(
468470
469471 if cc == gc and cr == gr :
470472 path = _jps_reconstruct (came_from , current , terrain , near_z )
471- path = _simplify_path (path , terrain )
472- path = vary_path (path , terrain , jitter_range = jitter )
473+ path = _simplify_path (path , terrain , wb_override = wb , wbc_override = wbc )
474+ path = vary_path (
475+ path , terrain , jitter_range = jitter , near_z = near_z , wb_override = wb , wbc_override = wbc
476+ )
473477 elapsed = time .perf_counter () - t0
474478 log .info ("[NAV] A* fallback: path %d waypoints, %d nodes in %.2fs" , len (path ), explored , elapsed )
475479 return path
@@ -780,7 +784,12 @@ def _reconstruct(
780784 return waypoints
781785
782786
783- def _simplify_path (path : list [Point ], terrain : ZoneTerrain ) -> list [Point ]:
787+ def _simplify_path (
788+ path : list [Point ],
789+ terrain : ZoneTerrain ,
790+ wb_override : bytearray | None = None ,
791+ wbc_override : int | None = None ,
792+ ) -> list [Point ]:
784793 """Two-phase path smoothing: greedy LOS + clearance-aware funnel.
785794
786795 Phase 1 (greedy forward): scan forward from each anchor, skip
@@ -790,6 +799,9 @@ def _simplify_path(path: list[Point], terrain: ZoneTerrain) -> list[Point]:
790799 try the FARTHEST remaining point first (reverse scan). Prefer paths
791800 with clearance margin; fall back to center-only for narrow passages.
792801 Finds shortcuts that Phase 1 misses due to its early-break heuristic.
802+
803+ When *wb_override*/*wbc_override* are provided, LOS checks use the
804+ Z-filtered bitfield so smoothing respects multi-level terrain.
793805 """
794806 if len (path ) <= 2 :
795807 return path
@@ -800,21 +812,25 @@ def _simplify_path(path: list[Point], terrain: ZoneTerrain) -> list[Point]:
800812 while i < len (path ) - 1 :
801813 best_j = i + 1
802814 for j in range (i + 2 , len (path )):
803- if _clear_line (terrain , path [i ][0 ], path [i ][1 ], path [j ][0 ], path [j ][1 ]):
815+ if _clear_line (
816+ terrain , path [i ][0 ], path [i ][1 ], path [j ][0 ], path [j ][1 ], wb_override , wbc_override
817+ ):
804818 best_j = j
805819 else :
806820 break
807821 phase1 .append (path [best_j ])
808822 i = best_j
809823
810824 # Phase 2: reverse-scan funnel with clearance
811- return _funnel_smooth (phase1 , terrain )
825+ return _funnel_smooth (phase1 , terrain , wb_override = wb_override , wbc_override = wbc_override )
812826
813827
814828def _funnel_smooth (
815829 path : list [Point ],
816830 terrain : ZoneTerrain ,
817831 clearance : float = 2.0 ,
832+ wb_override : bytearray | None = None ,
833+ wbc_override : int | None = None ,
818834) -> list [Point ]:
819835 """Clearance-aware reverse-scan funnel smoothing.
820836
@@ -840,14 +856,18 @@ def _funnel_smooth(
840856
841857 # Try farthest point first -- with clearance
842858 for j in range (len (path ) - 1 , i + 1 , - 1 ):
843- if _clear_line_wide (terrain , path [i ][0 ], path [i ][1 ], path [j ][0 ], path [j ][1 ], clearance ):
859+ if _clear_line_wide (
860+ terrain , path [i ][0 ], path [i ][1 ], path [j ][0 ], path [j ][1 ], clearance , wb_override , wbc_override
861+ ):
844862 best_j = j
845863 break
846864
847865 # Fallback: center-only for narrow passages
848866 if best_j == i + 1 and i + 2 < len (path ):
849867 for j in range (len (path ) - 1 , i + 1 , - 1 ):
850- if _clear_line (terrain , path [i ][0 ], path [i ][1 ], path [j ][0 ], path [j ][1 ]):
868+ if _clear_line (
869+ terrain , path [i ][0 ], path [i ][1 ], path [j ][0 ], path [j ][1 ], wb_override , wbc_override
870+ ):
851871 best_j = j
852872 break
853873
@@ -857,16 +877,39 @@ def _funnel_smooth(
857877 return result
858878
859879
860- def vary_path (path : list [Point ], terrain : ZoneTerrain , jitter_range : float = 3.0 ) -> list [Point ]:
880+ def vary_path (
881+ path : list [Point ],
882+ terrain : ZoneTerrain ,
883+ jitter_range : float = 3.0 ,
884+ near_z : float = float ("nan" ),
885+ wb_override : bytearray | None = None ,
886+ wbc_override : int | None = None ,
887+ ) -> list [Point ]:
861888 """Add small perpendicular offsets to intermediate waypoints.
862889
863890 Applies a uniform lateral drift (1-3u) to each interior point.
864891 Start/end waypoints are never modified. All offset positions are
865892 validated against terrain before use.
893+
894+ When *near_z* and a Z-filtered bitfield are provided, jitter
895+ validation uses the bitfield (respecting multi-level terrain) and
896+ Z lookup uses ``get_level_z`` to stay on the correct floor.
866897 """
867898 if len (path ) <= 2 :
868899 return path
869900
901+ # Pre-fetch grid constants for bitfield walkability checks
902+ _use_zbit = wb_override is not None and wbc_override is not None
903+ if _use_zbit :
904+ _cs = terrain .cell_size
905+ _mx = terrain ._min_x
906+ _my = terrain ._min_y
907+ _cols = terrain ._cols
908+ _rows = terrain ._rows
909+ _bw = _bit_walkable
910+
911+ _has_near_z = not math .isnan (near_z )
912+
870913 result : list [Point ] = [path [0 ]]
871914
872915 for i in range (1 , len (path ) - 1 ):
@@ -882,21 +925,44 @@ def vary_path(path: list[Point], terrain: ZoneTerrain, jitter_range: float = 3.0
882925 offset = random .uniform (- jitter_range , jitter_range )
883926 jx = px + perp_x * offset
884927 jy = py + perp_y * offset
885- if terrain .is_walkable (jx , jy ):
928+ # Z-aware walkability: use the filtered bitfield when available
929+ if _use_zbit :
930+ col = int ((jy - _mx ) / _cs )
931+ row = int ((jx - _my ) / _cs )
932+ walkable = _bw (wb_override , wbc_override , col , row , _cols , _rows )
933+ else :
934+ walkable = terrain .is_walkable (jx , jy )
935+ if walkable :
886936 px , py = jx , jy
887937
888- _z = terrain .get_z (px , py )
938+ # Z-aware height: use get_level_z to stay on the correct floor
939+ if _has_near_z :
940+ _z = terrain .get_level_z (px , py , near_z )
941+ else :
942+ _z = terrain .get_z (px , py )
889943 result .append (Point (px , py , _z if _z == _z else 0.0 ))
890944
891945 result .append (path [- 1 ])
892946 return result
893947
894948
895- def _clear_line (terrain : ZoneTerrain , x1 : float , y1 : float , x2 : float , y2 : float ) -> bool :
949+ def _clear_line (
950+ terrain : ZoneTerrain ,
951+ x1 : float ,
952+ y1 : float ,
953+ x2 : float ,
954+ y2 : float ,
955+ wb_override : bytearray | None = None ,
956+ wbc_override : int | None = None ,
957+ ) -> bool :
896958 """Check if a straight line between two game-coord points is walkable.
897959
898960 Uses precomputed bitfield when available (~10x faster than
899961 _cell_walkable per sample). Falls back to _cell_walkable otherwise.
962+
963+ When *wb_override*/*wbc_override* are provided (e.g. the Z-filtered
964+ bitfield from ``build_walk_bits_z``), they take precedence over the
965+ cached Z-agnostic ``terrain._walk_bits``.
900966 """
901967 dx , dy = x2 - x1 , y2 - y1
902968 dist = math .hypot (dx , dy )
@@ -907,9 +973,9 @@ def _clear_line(terrain: ZoneTerrain, x1: float, y1: float, x2: float, y2: float
907973 steps = max (1 , int (dist / (cs * 0.5 )))
908974
909975 # Fast path: bitfield-accelerated (no coord conversion overhead)
910- wb = terrain ._walk_bits
976+ wb = wb_override if wb_override is not None else terrain ._walk_bits
911977 if wb :
912- wbc = terrain ._walk_byte_cols
978+ wbc = wbc_override if wbc_override is not None else terrain ._walk_byte_cols
913979 cols = terrain ._cols
914980 rows = terrain ._rows
915981 mx = terrain ._min_x
@@ -936,14 +1002,21 @@ def _clear_line(terrain: ZoneTerrain, x1: float, y1: float, x2: float, y2: float
9361002
9371003
9381004def _clear_line_wide (
939- terrain : ZoneTerrain , x1 : float , y1 : float , x2 : float , y2 : float , clearance : float = 2.0
1005+ terrain : ZoneTerrain ,
1006+ x1 : float ,
1007+ y1 : float ,
1008+ x2 : float ,
1009+ y2 : float ,
1010+ clearance : float = 2.0 ,
1011+ wb_override : bytearray | None = None ,
1012+ wbc_override : int | None = None ,
9401013) -> bool :
9411014 """Check if a line with clearance margin is fully walkable.
9421015
9431016 Tests center line plus two parallel lines offset by clearance.
9441017 Ensures the agent has breathing room and won't wall-hug.
9451018 """
946- if not _clear_line (terrain , x1 , y1 , x2 , y2 ):
1019+ if not _clear_line (terrain , x1 , y1 , x2 , y2 , wb_override , wbc_override ):
9471020 return False
9481021
9491022 dx , dy = x2 - x1 , y2 - y1
@@ -955,8 +1028,8 @@ def _clear_line_wide(
9551028 px = - dy / dist * clearance
9561029 py = dx / dist * clearance
9571030
958- if not _clear_line (terrain , x1 + px , y1 + py , x2 + px , y2 + py ):
1031+ if not _clear_line (terrain , x1 + px , y1 + py , x2 + px , y2 + py , wb_override , wbc_override ):
9591032 return False
960- if not _clear_line (terrain , x1 - px , y1 - py , x2 - px , y2 - py ):
1033+ if not _clear_line (terrain , x1 - px , y1 - py , x2 - px , y2 - py , wb_override , wbc_override ):
9611034 return False
9621035 return True
0 commit comments