Files
2026-10-11 22:06:14 +02:00

268 lines
11 KiB
Python

"""
Floating settings panel for depth3d_vr. Drawn with Pillow into a texture and operated
with VR controller laser pointers (trigger = click / drag).
"""
import time
import numpy as np
from PIL import Image, ImageDraw, ImageFont
PW, PH = 1280, 800 # panel texture size in pixels
PANEL_W_M = 1.6 # physical size in the VR world (metres)
PANEL_H_M = PANEL_W_M * PH / PW
COL_BG = (16, 18, 26, 240)
COL_TEXT = (235, 238, 245, 255)
COL_DIM = (150, 156, 172, 255)
COL_ACCENT = (64, 156, 255, 255)
COL_WIDGET = (48, 52, 68, 255)
COL_HOVER = (72, 78, 100, 255)
COL_WARN = (255, 190, 80, 255)
TABS = ["3D", "Screen", "Environment"]
_fonts = {}
def font(size, bold=False):
key = (size, bold)
if key in _fonts:
return _fonts[key]
names = (["segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf"] if bold
else ["segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"])
f = None
for n in names:
try:
f = ImageFont.truetype(n, size)
break
except OSError:
continue
if f is None:
f = ImageFont.load_default(size)
_fonts[key] = f
return f
def ray_panel_hit(origin, direction, inv_model):
"""Intersect a world-space ray with the panel. Returns (t, px, py) or None."""
o = inv_model @ np.append(origin, 1.0)
d = inv_model[:3, :3] @ direction
if abs(d[2]) < 1e-6:
return None
t = -o[2] / d[2]
if t <= 0:
return None
x, y = o[0] + t * d[0], o[1] + t * d[1]
if abs(x) > PANEL_W_M / 2 or abs(y) > PANEL_H_M / 2:
return None
return t, (x / PANEL_W_M + 0.5) * PW, (0.5 - y / PANEL_H_M) * PH
class Panel:
def __init__(self, st, on_action):
self.st = st
self.on_action = on_action
self.tab = 0
self.info = dict(env_name="", env_layout="", env_video=False, env_status="", ipd=0.063)
self.widgets = []
self.hover = None
self.active = None
self._down = False
self.dirty = True
self._toast = ("", 0.0)
self._layout()
# ------------------------------------------------------------------ state
def set_info(self, **kw):
if any(self.info.get(k) != v for k, v in kw.items()):
self.info.update(kw)
self.dirty = True
self._layout()
def toast(self, text, seconds=2.5):
self._toast = (text, time.time() + seconds)
self.dirty = True
def invalidate(self):
self.dirty = True
self._layout()
# ------------------------------------------------------------------ layout
def _layout(self):
W = []
for i, name in enumerate(TABS):
W.append(dict(t="tab", idx=i, label=name, rect=(60 + i * 230, 92, 60 + i * 230 + 210, 148)))
y = 190
row = 84
def slider(key, label, lo, hi, fmt):
nonlocal y
W.append(dict(t="slider", key=key, label=label, lo=lo, hi=hi, fmt=fmt,
rect=(500, y - 28, 1010, y + 28), track=(520, 990), y=y))
y += row
def toggle(key, label):
nonlocal y
W.append(dict(t="toggle", key=key, label=label, rect=(500, y - 28, 640, y + 28), y=y))
y += row
def button(action, label, x0=60, x1=480, big=False):
W.append(dict(t="button", action=action, label=label, rect=(x0, y - 30, x1, y + 30), y=y))
return y
if self.tab == 0:
slider("depth_cm", "3D depth", 0.0, 30.0, "{:.1f} cm")
slider("conv", "Convergence", 0.0, 1.0, "{:.2f}")
toggle("stereo3d", "3D enabled")
toggle("swap", "Swap eyes")
toggle("debug", "Show depth map")
elif self.tab == 1:
slider("fov", "Screen size", 20.0, 180.0, "{:.0f}\u00b0")
slider("distance", "Distance", 1.0, 10.0, "{:.1f} m")
slider("height", "Height", -1.5, 1.5, "{:+.2f} m")
slider("sharpen", "Text sharpening", 0.0, 1.0, "{:.2f}")
button("recenter", "Recenter screen", 500, 900)
y += row
else:
W.append(dict(t="button", action="env_prev", label="\u25c0", rect=(60, y - 30, 160, y + 30), y=y))
W.append(dict(t="button", action="env_next", label="\u25b6", rect=(1120, y - 30, 1220, y + 30), y=y))
y += row
W.append(dict(t="button", action="env_layout", label="Layout: " + (self.info["env_layout"] or "-"),
rect=(60, y - 30, 600, y + 30), y=y))
y += row
slider("env_brightness", "Brightness", 0.0, 1.5, "{:.2f}")
slider("env_yaw", "Rotation", -180.0, 180.0, "{:+.0f}\u00b0")
if self.info["env_video"]:
toggle("env_play", "Play video")
fy = 742
for i, (label, action) in enumerate((("Save", "save"), ("Reset", "reset"), ("Close", "close"))):
W.append(dict(t="button", action=action, label=label,
rect=(60 + i * 240, fy - 32, 60 + i * 240 + 210, fy + 32), y=fy))
self.widgets = W
self.dirty = True
# ------------------------------------------------------------------ interaction
def _widget_at(self, px, py):
for w in self.widgets:
x0, y0, x1, y1 = w["rect"]
if x0 <= px <= x1 and y0 <= py <= y1:
return w
return None
def _set_slider(self, w, px):
t0, t1 = w["track"]
t = min(1.0, max(0.0, (px - t0) / (t1 - t0)))
self.st[w["key"]] = w["lo"] + t * (w["hi"] - w["lo"])
self.dirty = True
def pointer(self, px, py, down):
"""Feed the pointer position in panel pixels (None = not pointing at the panel)."""
press = down and not self._down
self._down = down
if px is None:
if self.hover is not None:
self.hover = None
self.dirty = True
if not down:
self.active = None
return
w = self._widget_at(px, py)
if w is not self.hover:
self.hover = w
self.dirty = True
if not down:
self.active = None
return
if self.active is not None:
self._set_slider(self.active, px)
return
if not press or w is None:
return
if w["t"] == "slider":
self.active = w
self._set_slider(w, px)
elif w["t"] == "toggle":
self.st[w["key"]] = not self.st[w["key"]]
self.dirty = True
self.on_action("toggled:" + w["key"])
elif w["t"] == "button":
self.on_action(w["action"])
elif w["t"] == "tab":
self.tab = w["idx"]
self.hover = None
self._layout()
# ------------------------------------------------------------------ drawing
def render_if_dirty(self):
if self._toast[0] and time.time() > self._toast[1]:
self._toast = ("", 0.0)
self.dirty = True
if not self.dirty:
return None
self.dirty = False
return self.render()
def render(self):
img = Image.new("RGBA", (PW, PH), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((0, 0, PW - 1, PH - 1), radius=40, fill=COL_BG)
d.text((60, 46), "Depth 3D", font=font(44, True), fill=COL_TEXT, anchor="lm")
d.text((PW - 60, 46), "trigger: click / drag", font=font(24), fill=COL_DIM, anchor="rm")
f_label, f_val = font(32), font(32, True)
for w in self.widgets:
hov = w is self.hover or w is self.active
x0, y0, x1, y1 = w["rect"]
if w["t"] == "tab":
sel = w["idx"] == self.tab
d.rounded_rectangle(w["rect"], radius=18,
fill=COL_ACCENT if sel else (COL_HOVER if hov else COL_WIDGET))
d.text(((x0 + x1) / 2, (y0 + y1) / 2), w["label"], font=font(30, True),
fill=COL_TEXT, anchor="mm")
elif w["t"] == "slider":
d.text((60, w["y"]), w["label"], font=f_label, fill=COL_TEXT, anchor="lm")
t0, t1 = w["track"]
val = float(self.st[w["key"]])
frac = min(1.0, max(0.0, (val - w["lo"]) / (w["hi"] - w["lo"])))
kx = t0 + frac * (t1 - t0)
d.rounded_rectangle((t0, w["y"] - 6, t1, w["y"] + 6), radius=6, fill=COL_WIDGET)
d.rounded_rectangle((t0, w["y"] - 6, kx, w["y"] + 6), radius=6, fill=COL_ACCENT)
d.ellipse((kx - 20, w["y"] - 20, kx + 20, w["y"] + 20),
fill=(255, 255, 255, 255) if hov else COL_TEXT)
d.text((1040, w["y"]), w["fmt"].format(val), font=f_val, fill=COL_TEXT, anchor="lm")
elif w["t"] == "toggle":
d.text((60, w["y"]), w["label"], font=f_label, fill=COL_TEXT, anchor="lm")
on = bool(self.st[w["key"]])
d.rounded_rectangle(w["rect"], radius=28,
fill=COL_ACCENT if on else (COL_HOVER if hov else COL_WIDGET))
kx = x1 - 28 if on else x0 + 28
d.ellipse((kx - 22, w["y"] - 22, kx + 22, w["y"] + 22), fill=COL_TEXT)
elif w["t"] == "button":
d.rounded_rectangle(w["rect"], radius=20, fill=COL_HOVER if hov else COL_WIDGET)
d.text(((x0 + x1) / 2, (y0 + y1) / 2), w["label"], font=font(30, True),
fill=COL_TEXT, anchor="mm")
if self.tab == 0:
far_cm = self.st["depth_cm"] * self.st["conv"]
if far_cm / 100.0 > self.info["ipd"]:
d.text((60, 640), "Far parts exceed your eye spacing - lower depth or convergence.",
font=font(26), fill=COL_WARN, anchor="lm")
else:
d.text((60, 640), "Tip: keep depth x convergence under about 6 for comfort.",
font=font(26), fill=COL_DIM, anchor="lm")
elif self.tab == 2:
name = self.info["env_name"] or "None (black void)"
d.text((640, 190), name, font=font(32, True), fill=COL_TEXT, anchor="mm")
status = self.info["env_status"]
if status:
d.text((60, 640), status, font=font(26), fill=COL_WARN, anchor="lm")
else:
d.text((60, 640), "Put 360/180 images or videos in the 'environments' folder.",
font=font(26), fill=COL_DIM, anchor="lm")
if self._toast[0]:
d.text((PW - 60, 742), self._toast[0], font=font(30, True), fill=COL_ACCENT, anchor="rm")
return img.tobytes()