Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions Py4GWCoreLib/Map.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
from Py4GWCoreLib.enums import outposts

import PyOverlay
import PyMap

from .native_src.internals import raycast_math as _rc

from .enums import FlagPreference
from typing import List, Optional
Expand Down Expand Up @@ -2306,6 +2309,138 @@ def sign(x1, y1, x2, y2, x3, y3):

return False

class Raycast:
"""World-collision raycast primitives (terrain + props), backed by native PyMap.

Geometry-only and agent-agnostic: callers pass exact world (x, y, z) endpoints.
ALL math (normalize, the blocked decision, hit reconstruction, the prop-mesh v*M
transform) is done here in Python on top of the raw native bridge -- the native
side returns engine numbers verbatim. Results are NOT @frame_cache'd (they vary by
arbitrary start/end). GW world Z is negative-up; any eye-height lift is the
caller's concern and is kept out of these primitives.

Mirrors the Map.Pathing nested-class precedent. Result types are re-exported as
Map.Raycast.RaycastHit / PropHit / PropInfo.
"""

RaycastHit = _rc.RaycastHit
PropHit = _rc.PropHit
PropInfo = _rc.PropInfo
DEFAULT_SLACK = _rc.DEFAULT_SLACK

@staticmethod
def Cast(start, end, slack: float = _rc.DEFAULT_SLACK) -> _rc.RaycastHit:
"""Combined terrain + walkable-prop cast (engine MapCliQueryIntersection).

.blocked is the line-of-sight answer; .point is the contact; .source is
'terrain' or 'props' decided from the engine prop_layer.
"""
unit_dir, seg_len = _rc.direction(start, end)
if unit_dir is None:
return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE)
try:
has_hit, hx, hy, hz, prop_layer = PyMap.RayCast(
(start[0], start[1], start[2]), unit_dir)
except Exception:
return _rc.RaycastHit(False, None, None, _rc.SOURCE_UNAVAILABLE)
if not has_hit:
return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE)
point = (float(hx), float(hy), float(hz))
hit_dist = _rc.segment_length(start, point)
if _rc.is_blocked(hit_dist, seg_len, slack):
source = _rc.SOURCE_TERRAIN if int(prop_layer) == 0 else _rc.SOURCE_PROPS
return _rc.RaycastHit(True, point, hit_dist, source)
return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE)

@staticmethod
def CastTerrain(start, end, slack: float = _rc.DEFAULT_SLACK) -> _rc.RaycastHit:
"""Terrain-only cast (engine TerrainQueryIntersection).

The bound terrain kernel writes the hit as a parametric FRACTION in [0,1] along
start->end, NOT a world distance, so it is scaled by seg_len here to get the
world hit distance (hit = start + frac*(end-start)).
"""
unit_dir, seg_len = _rc.direction(start, end)
if unit_dir is None:
return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE)
try:
has_hit, frac = PyMap.RayCastTerrain(
(start[0], start[1], start[2]), (end[0], end[1], end[2]))
except Exception:
return _rc.RaycastHit(False, None, None, _rc.SOURCE_UNAVAILABLE)
dist = float(frac) * seg_len # fraction [0,1] along the segment -> world distance
if has_hit and _rc.is_blocked(dist, seg_len, slack):
point = _rc.point_along(start, unit_dir, dist)
return _rc.RaycastHit(True, point, dist, _rc.SOURCE_TERRAIN)
return _rc.RaycastHit(False, None, None, _rc.SOURCE_NONE)

@staticmethod
def CastInteractive(start, end, slack: float = _rc.DEFAULT_SLACK) -> _rc.PropHit:
"""Interactive-prop mesh cast (engine IProps::IntersectGeometry over every prop).

n_scanned == -1 signals the probe could not run (stale DLL); n_scanned == 0 from
a successful call means the map has no interactive-prop meshes (a clear result).
"""
unit_dir, seg_len = _rc.direction(start, end)
if unit_dir is None:
return _rc.PropHit(False, None, None, -1, 0)
try:
has_hit, dist, prop_id, n_scanned = PyMap.RayCastInteractive(
(start[0], start[1], start[2]), unit_dir, seg_len)
except Exception:
return _rc.PropHit(False, None, None, -1, -1)
dist = float(dist)
prop_id = int(prop_id)
n_scanned = int(n_scanned)
if has_hit and _rc.is_blocked(dist, seg_len, slack):
point = _rc.point_along(start, unit_dir, dist)
return _rc.PropHit(True, point, dist, prop_id, n_scanned)
return _rc.PropHit(False, None, None, -1, n_scanned)

@staticmethod
def HasLos(start, end, terrain: bool = True, props: bool = True,
slack: float = _rc.DEFAULT_SLACK) -> _rc.RaycastHit:
"""Reference HasLos: nearest-block combine of CastTerrain + CastInteractive.

blocked = terrain OR a prop; the nearest block wins. .blocked is the LoS answer,
.point is the nearest blocking contact. Probes a source only when its flag is set,
so toggling one off both skips its native raycast and excludes it from blocking.
"""
hits = []
if terrain:
hits.append(Map.Raycast.CastTerrain(start, end, slack))
if props:
prop = Map.Raycast.CastInteractive(start, end, slack)
if prop.n_scanned < 0: # probe unavailable (stale DLL)
hits.append(_rc.RaycastHit(False, None, None, _rc.SOURCE_UNAVAILABLE))
else:
src = _rc.SOURCE_PROPS if prop.blocked else _rc.SOURCE_NONE
hits.append(_rc.RaycastHit(prop.blocked, prop.point, prop.distance, src))
return _rc.nearest_block(hits)

@staticmethod
def GetProps() -> "list[_rc.PropInfo]":
"""Enumerate all map props (world position + collision flags)."""
try:
raw = PyMap.GetProps()
except Exception:
return []
return [_rc.PropInfo(int(p[0]), float(p[1]), float(p[2]), float(p[3]),
bool(p[4]), int(p[5])) for p in raw]

@staticmethod
def GetPropGeometry(prop_id: int) -> "list[_rc.Triangle]":
"""One prop's collision mesh as world-space triangles (applies v*M in Python).

Returns a flat list of 9-float (x1,y1,z1, x2,y2,z2, x3,y3,z3) world triangles,
ready for Overlay.DrawTriangle3D. Empty if the prop has no collision geometry.
"""
try:
submodels = PyMap.GetPropGeometry(int(prop_id))
except Exception:
return []
return _rc.transform_local_triangles(submodels)




2 changes: 2 additions & 0 deletions Py4GWCoreLib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def find_system_python():
import PyCamera
import Py2DRenderer
import PyCombatEvents
import PyMap

from .enums import *
from .ImGui_src.IconsFontAwesome5 import IconsFontAwesome5
Expand Down Expand Up @@ -132,6 +133,7 @@ def find_system_python():
PyCamera = PyCamera
Py2DRenderer = Py2DRenderer
PyCombatEvents = PyCombatEvents
PyMap = PyMap
GLOBAL_CACHE = GLOBAL_CACHE
AutoPathing = AutoPathing
IconsFontAwesome5 = IconsFontAwesome5
Expand Down
148 changes: 148 additions & 0 deletions Py4GWCoreLib/native_src/internals/raycast_math.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Pure-Python math + result types for ``Map.Raycast``.

This module is the home for ALL the geometry math that was moved off the native
PyMap bridge: segment length, normalize, hit-point reconstruction, the blocked
decision and the prop-mesh ``v*M`` transform. It has NO native (DLL) dependency,
so it is unit-testable in isolation -- the native side returns raw engine numbers
and everything derived happens here.

GW world Z is negative-up. These primitives are geometry-only and agent-agnostic:
callers pass exact world endpoints. Any eye-height lift is the caller's concern and
is deliberately kept out of here.
"""
import math
from typing import Iterable, List, NamedTuple, Optional, Tuple

Vec3 = Tuple[float, float, float]
Matrix12 = Tuple[float, float, float, float, float, float,
float, float, float, float, float, float]
Triangle = Tuple[float, float, float, float, float, float, float, float, float]

# An obstruction within this many world units of the segment end is treated as the
# endpoint's own footing / float noise, not a block. Matches the literal the C++ side
# used before the blocked decision moved to Python.
DEFAULT_SLACK = 8.0

# Segments shorter than this are degenerate; normalize would divide by ~0, so the
# raycast primitives report "clear" without calling the engine. Matches the old native
# ``seg_len <= 1.0`` short-circuit.
MIN_SEGMENT_LENGTH = 1.0

# Result-source tags for RaycastHit.source.
SOURCE_NONE = "none" # clear line of sight
SOURCE_TERRAIN = "terrain" # blocked by terrain
SOURCE_PROPS = "props" # blocked by a prop / interactive mesh
SOURCE_UNAVAILABLE = "unavailable" # probe could not run (stale DLL / no map context)


class RaycastHit(NamedTuple):
"""Result of a point->point cast / line-of-sight query.

blocked : True if an obstruction sits before the segment end (minus slack).
point : nearest contact point (world x,y,z), or None when clear / unavailable.
distance : distance from start to ``point``, or None.
source : which test produced the answer -- 'terrain', 'props', 'none' (clear) or
'unavailable' (probe could not run). For the combined engine cast,
'terrain'/'props' is decided from the engine's prop_layer.
"""
blocked: bool
point: Optional[Vec3]
distance: Optional[float]
source: str


class PropHit(NamedTuple):
"""Result of an interactive-prop mesh cast (adds prop_id / n_scanned).

n_scanned == -1 is the sentinel for "probe could not run" (the native call raised:
stale DLL / missing symbol); n_scanned == 0 from a successful call simply means the
map carries no interactive-prop meshes (a legitimate clear).
"""
blocked: bool
point: Optional[Vec3]
distance: Optional[float]
prop_id: int
n_scanned: int


class PropInfo(NamedTuple):
"""One enumerated map prop (``Map.Raycast.GetProps``)."""
prop_id: int
x: float
y: float
z: float
is_interactive: bool
rec_count: int


# ----- vector helpers -------------------------------------------------------

def segment_length(start: Vec3, end: Vec3) -> float:
"""Euclidean distance between two world points."""
return math.dist(start, end)


def direction(start: Vec3, end: Vec3) -> Tuple[Optional[Vec3], float]:
"""Return (unit_dir, seg_len). unit_dir is None for a degenerate segment."""
seg_len = math.dist(start, end)
if seg_len <= MIN_SEGMENT_LENGTH:
return None, seg_len
inv = 1.0 / seg_len
return (((end[0] - start[0]) * inv,
(end[1] - start[1]) * inv,
(end[2] - start[2]) * inv), seg_len)


def point_along(start: Vec3, unit_dir: Vec3, dist: float) -> Vec3:
"""Reconstruct the world point at ``dist`` along unit_dir from start."""
return (start[0] + dist * unit_dir[0],
start[1] + dist * unit_dir[1],
start[2] + dist * unit_dir[2])


def is_blocked(hit_dist: float, seg_len: float, slack: float = DEFAULT_SLACK) -> bool:
"""A hit blocks LoS only if it sits before the segment end minus slack."""
return hit_dist < seg_len - slack


def transform_point(matrix12: Matrix12, v: Vec3) -> Vec3:
"""Apply a row-major 3x4 affine world matrix to a local vertex (v*M)."""
lx, ly, lz = v
m = matrix12
return (m[0] * lx + m[1] * ly + m[2] * lz + m[3],
m[4] * lx + m[5] * ly + m[6] * lz + m[7],
m[8] * lx + m[9] * ly + m[10] * lz + m[11])


def transform_local_triangles(submodels: Iterable) -> List[Triangle]:
"""Flatten native GetPropGeometry output to world-space triangles.

Input: an iterable of (matrix12, tris_local) per submodel, where each local
triangle is a 9-float (lx1,ly1,lz1, lx2,ly2,lz2, lx3,ly3,lz3). Applies v*M per
vertex and returns a flat list of world-space 9-float triangles.
"""
world: List[Triangle] = []
for matrix12, tris_local in submodels:
for tri in tris_local:
w0 = transform_point(matrix12, (tri[0], tri[1], tri[2]))
w1 = transform_point(matrix12, (tri[3], tri[4], tri[5]))
w2 = transform_point(matrix12, (tri[6], tri[7], tri[8]))
world.append((w0[0], w0[1], w0[2],
w1[0], w1[1], w1[2],
w2[0], w2[1], w2[2]))
return world


def nearest_block(hits: Iterable[RaycastHit]) -> RaycastHit:
"""Combine cast results: the nearest blocking hit wins; clear if none block.

If no source blocks but every supplied probe was unavailable, the combined result
is 'unavailable' (so callers do not read a failed probe as clear line of sight).
"""
hits = list(hits)
blocking = [h for h in hits if h.blocked and h.distance is not None]
if blocking:
return min(blocking, key=lambda h: h.distance)
if hits and all(h.source == SOURCE_UNAVAILABLE for h in hits):
return RaycastHit(False, None, None, SOURCE_UNAVAILABLE)
return RaycastHit(False, None, None, SOURCE_NONE)
Loading