Files
AWCharacterCreator/backend/app/vision.py
T
Claude 82e4ac9458 Maps tab with sharing, walls/doors and strict line of sight
Maps page (list, upload, edit, share, copy), wall/room/door tools in the
map editor, server-side visibility polygon: the image is cut per player
and other tokens are filtered, DM can view as a player, open doors and
switch sight on/off.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-23 14:23:17 +00:00

133 lines
4.0 KiB
Python

"""Line of sight: visibility polygon from a point around wall segments, and cutting the map image to it.
The server cuts the image, so a player's browser only ever receives the pixels their character can see (no peeking through
dev tools). Pure Python on purpose (no numpy): a few hundred wall segments are fast enough, and results are cached."""
import io
import math
from collections import OrderedDict
from PIL import Image, ImageDraw
EPS = 1e-5
def polygon(origin, segs, w, h, radius=0.0):
"""Points of the area visible from origin. segs: [(x1, y1, x2, y2)] that block sight; radius 0 means unlimited."""
ox, oy = origin
border = [(0, 0, w, 0), (w, 0, w, h), (w, h, 0, h), (0, h, 0, 0)]
if radius:
r2 = (radius + 1) ** 2
def near(s):
x1, y1, x2, y2 = s
dx, dy = x2 - x1, y2 - y1
L = dx * dx + dy * dy
t = 0 if L == 0 else max(0, min(1, ((ox - x1) * dx + (oy - y1) * dy) / L))
return (x1 + t * dx - ox) ** 2 + (y1 + t * dy - oy) ** 2 <= r2
segs = [s for s in segs if near(s)]
allsegs = segs + border
angles = []
for x1, y1, x2, y2 in allsegs:
for x, y in ((x1, y1), (x2, y2)):
if radius and (x - ox) ** 2 + (y - oy) ** 2 > (radius * 3) ** 2:
continue
a = math.atan2(y - oy, x - ox)
angles += (a - EPS, a, a + EPS)
if radius:
angles += [i * (2 * math.pi / 120) for i in range(120)]
angles.sort()
pts = []
for a in angles:
dx, dy = math.cos(a), math.sin(a)
best = radius if radius else float("inf")
for x1, y1, x2, y2 in allsegs:
ex, ey = x2 - x1, y2 - y1
den = dx * ey - dy * ex
if abs(den) < 1e-12:
continue
px, py = x1 - ox, y1 - oy
t = (px * ey - py * ex) / den
if t < 0 or t >= best:
continue
u = (px * dy - py * dx) / den
if -1e-9 <= u <= 1 + 1e-9:
best = t
if best == float("inf"):
continue
pts.append((ox + dx * best, oy + dy * best))
return pts
def inside(pt, poly) -> bool:
x, y = pt
c = False
j = len(poly) - 1
for i in range(len(poly)):
xi, yi = poly[i]
xj, yj = poly[j]
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
c = not c
j = i
return c
class Lru(OrderedDict):
def __init__(self, size):
super().__init__()
self.size = size
def put(self, k, v):
self[k] = v
self.move_to_end(k)
while len(self) > self.size:
self.popitem(last=False)
def fetch(self, k):
if k in self:
self.move_to_end(k)
return self[k]
_images = Lru(2)
_polys = Lru(128)
_cuts = Lru(24)
def cached_polygon(key, make):
v = _polys.fetch(key)
if v is None:
v = make()
_polys.put(key, v)
return v
def source(path):
key = (str(path), path.stat().st_mtime_ns)
im = _images.fetch(key)
if im is None:
im = Image.open(path)
im.load()
im = im.convert("RGB")
_images.put(key, im)
return im
def cut(path, poly, key):
"""The part of the image inside the polygon (everything else black), cropped to its bounding box.
Returns (webp bytes, (x, y, w, h))."""
hit = _cuts.fetch(key)
if hit:
return hit
im = source(path)
xs, ys = [p[0] for p in poly], [p[1] for p in poly]
x0, y0 = max(0, int(math.floor(min(xs)))), max(0, int(math.floor(min(ys))))
x1, y1 = min(im.width, int(math.ceil(max(xs)))), min(im.height, int(math.ceil(max(ys))))
w, h = max(1, x1 - x0), max(1, y1 - y0)
mask = Image.new("L", (w, h), 0)
ImageDraw.Draw(mask).polygon([(x - x0, y - y0) for x, y in poly], fill=255)
piece = Image.composite(im.crop((x0, y0, x0 + w, y0 + h)), Image.new("RGB", (w, h), (0, 0, 0)), mask)
out = io.BytesIO()
piece.save(out, "WEBP", quality=82, method=1)
res = (out.getvalue(), (x0, y0, w, h))
_cuts.put(key, res)
return res