EndoGSim_demo / utils /interactive_viewer.py
TIANYu907's picture
Deploy the single-scene EndoGSim demo to the new Space.
a064299
Raw
History Blame Contribute Delete
30.7 kB
"""Browser-based 2D viewer: drag-to-apply force on mouse release."""
import io
import json
import mimetypes
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import numpy as np
import torch
from PIL import Image
from utils.interactive_scenes import scene_public_view
_FORCE_MIN = 1.0
_FORCE_MAX = 10.0
_INDEX_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>EndoGSim Interactive / 交互仿真</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #111;
color: #eee;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 0.4rem 0.75rem;
background: #1a1a1a;
border-bottom: 1px solid #333;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
align-items: center;
flex-shrink: 0;
}
header label { display: flex; align-items: center; gap: 0.4rem; }
header button {
background: #2d6cdf;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.35rem 0.75rem;
cursor: pointer;
}
header button:hover { background: #3a7af0; }
header button:disabled { opacity: 0.5; cursor: not-allowed; }
#status { margin-left: auto; font-size: 0.9rem; color: #aaa; max-width: 40%; text-align: right; }
#scene-bar {
flex-shrink: 0;
background: #151515;
border-bottom: 1px solid #333;
padding: 0.45rem 0.6rem;
display: flex;
align-items: stretch;
gap: 0.6rem;
overflow: hidden;
}
#scene-bar .scene-bar-label {
flex-shrink: 0;
font-size: 0.85rem;
font-weight: 600;
color: #aaa;
align-self: center;
padding-right: 0.15rem;
white-space: nowrap;
}
#scene-list {
display: flex;
flex-direction: row;
gap: 0.55rem;
overflow-x: auto;
overflow-y: hidden;
flex: 1;
min-width: 0;
padding-bottom: 0.15rem;
scrollbar-width: thin;
scrollbar-color: #444 #1a1a1a;
}
#scene-list::-webkit-scrollbar { height: 6px; }
#scene-list::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
.scene-card {
display: flex;
flex-direction: column;
flex: 0 0 148px;
width: 148px;
text-align: left;
background: #1e1e1e;
border: 2px solid #333;
border-radius: 8px;
padding: 0;
cursor: pointer;
color: inherit;
overflow: hidden;
transition: border-color 0.15s, background 0.15s;
}
.scene-card:hover { border-color: #555; background: #252525; }
.scene-card.active { border-color: #2d6cdf; background: #1a2744; }
.scene-card:disabled { opacity: 0.55; cursor: wait; }
.scene-card img {
display: block;
width: 100%;
height: 72px;
object-fit: cover;
background: #000;
}
.scene-card .scene-text { padding: 0.35rem 0.45rem 0.45rem; }
.scene-card .scene-name {
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25;
margin-bottom: 0.15rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.scene-card .scene-desc {
font-size: 0.68rem;
color: #888;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
#main-panel {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
position: relative;
overflow: hidden;
}
#view-wrap {
position: relative;
line-height: 0;
touch-action: none;
user-select: none;
cursor: crosshair;
}
#frame {
display: block;
width: auto;
height: auto;
border: 1px solid #333;
pointer-events: none;
image-rendering: auto;
}
#overlay {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: auto;
}
#overlay.disabled { pointer-events: none; cursor: wait; }
#loading-mask {
display: none;
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
align-items: center;
justify-content: center;
font-size: 1.1rem;
color: #ddd;
z-index: 10;
}
#loading-mask.show { display: flex; }
.hint { font-size: 0.85rem; color: #888; }
</style>
</head>
<body>
<header>
<label><input type="checkbox" id="paused" />
<span>Paused / 暂停</span></label>
<button type="button" id="reset">Reset / 重置</button>
<label><span>Force / 力度</span>
<input type="range" id="force" min="1" max="10" step="0.1" value="1" />
<span id="force-val">1.0</span>
</label>
<span class="hint">Drag &amp; release to push / 拖动后松开施力</span>
<span id="status">Ready / 就绪</span>
</header>
<div id="scene-bar">
<span class="scene-bar-label">Scenes / 场景</span>
<div id="scene-list"></div>
</div>
<div id="main-panel">
<div id="loading-mask">Loading scene / 加载场景中…</div>
<div id="view-wrap">
<img id="frame" alt="Simulation render / 仿真画面" draggable="false" />
<canvas id="overlay"></canvas>
</div>
</div>
<script>
const viewWrap = document.getElementById("view-wrap");
const mainPanel = document.getElementById("main-panel");
const frame = document.getElementById("frame");
const overlay = document.getElementById("overlay");
const loadingMask = document.getElementById("loading-mask");
const sceneList = document.getElementById("scene-list");
const ctx = overlay.getContext("2d");
const statusEl = document.getElementById("status");
const pausedCb = document.getElementById("paused");
const resetBtn = document.getElementById("reset");
const forceSlider = document.getElementById("force");
const forceVal = document.getElementById("force-val");
const headerEl = document.querySelector("header");
const sceneBarEl = document.getElementById("scene-bar");
let dragging = false;
let startX = 0;
let startY = 0;
let curX = 0;
let curY = 0;
let currentSceneId = null;
let sceneLoading = false;
let wasServerLoading = false;
let lastFrameVersion = -1;
function setSceneLoading(loading) {
sceneLoading = loading;
loadingMask.classList.toggle("show", loading);
overlay.classList.toggle("disabled", loading);
document.querySelectorAll(".scene-card").forEach((btn) => {
btn.disabled = loading;
});
}
function fitDisplaySize() {
const iw = frame.naturalWidth;
const ih = frame.naturalHeight;
if (!iw || !ih) return;
const pad = 12;
const maxW = Math.min(window.innerWidth - pad * 2, 960);
const maxH = window.innerHeight - headerEl.offsetHeight - sceneBarEl.offsetHeight - pad * 2;
const scale = Math.min(maxW / iw, maxH / ih, 2);
const w = Math.max(1, Math.round(iw * scale));
const h = Math.max(1, Math.round(ih * scale));
frame.style.width = w + "px";
frame.style.height = h + "px";
viewWrap.style.width = w + "px";
viewWrap.style.height = h + "px";
syncOverlaySize();
}
function syncOverlaySize() {
const rect = frame.getBoundingClientRect();
const w = Math.max(1, Math.round(rect.width));
const h = Math.max(1, Math.round(rect.height));
overlay.width = w;
overlay.height = h;
overlay.style.width = w + "px";
overlay.style.height = h + "px";
}
function clearOverlay() {
ctx.clearRect(0, 0, overlay.width, overlay.height);
}
function drawArrow(x0, y0, x1, y1) {
clearOverlay();
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.hypot(dx, dy);
if (len < 2) {
ctx.beginPath();
ctx.arc(x0, y0, 5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(80, 200, 255, 0.9)";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1.5;
ctx.stroke();
return;
}
ctx.strokeStyle = "#4af";
ctx.fillStyle = "#4af";
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
const head = Math.min(14, len * 0.35);
const angle = Math.atan2(dy, dx);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(
x1 - head * Math.cos(angle - Math.PI / 7),
y1 - head * Math.sin(angle - Math.PI / 7)
);
ctx.lineTo(
x1 - head * Math.cos(angle + Math.PI / 7),
y1 - head * Math.sin(angle + Math.PI / 7)
);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(x0, y0, 5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(80, 200, 255, 0.95)";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 1.5;
ctx.stroke();
}
function clientToOverlay(clientX, clientY) {
const rect = overlay.getBoundingClientRect();
return {
x: clientX - rect.left,
y: clientY - rect.top,
nx: (clientX - rect.left) / rect.width,
ny: (clientY - rect.top) / rect.height,
};
}
async function postJson(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return res.json();
}
overlay.addEventListener("pointerdown", (e) => {
if (sceneLoading) return;
e.preventDefault();
syncOverlaySize();
overlay.setPointerCapture(e.pointerId);
dragging = true;
const p = clientToOverlay(e.clientX, e.clientY);
startX = curX = p.x;
startY = curY = p.y;
drawArrow(startX, startY, curX, curY);
});
overlay.addEventListener("pointermove", (e) => {
if (!dragging) return;
e.preventDefault();
const p = clientToOverlay(e.clientX, e.clientY);
curX = p.x;
curY = p.y;
drawArrow(startX, startY, curX, curY);
});
async function finishDrag(e) {
if (!dragging) return;
dragging = false;
if (overlay.hasPointerCapture(e.pointerId)) {
overlay.releasePointerCapture(e.pointerId);
}
const end = clientToOverlay(e.clientX, e.clientY);
const startNorm = { x: startX / overlay.width, y: startY / overlay.height };
const endNorm = { x: end.nx, y: end.ny };
clearOverlay();
const data = await postJson("/api/drag_release", {
x0: startNorm.x,
y0: startNorm.y,
x1: endNorm.x,
y1: endNorm.y,
});
if (data.message) statusEl.textContent = data.message;
}
overlay.addEventListener("pointerup", finishDrag);
overlay.addEventListener("pointercancel", finishDrag);
pausedCb.addEventListener("change", () => {
postJson("/api/pause", { paused: pausedCb.checked });
});
resetBtn.addEventListener("click", async () => {
const data = await postJson("/api/reset", {});
if (data.message) statusEl.textContent = data.message;
});
forceSlider.addEventListener("input", () => {
forceVal.textContent = Number(forceSlider.value).toFixed(1);
});
forceSlider.addEventListener("change", () => {
postJson("/api/force", { magnitude: Number(forceSlider.value) });
});
function isStickyStatus(text) {
return /^(Force |Missed |Drag too short|Simulation reset|Invalid drag|Loaded:|施力 |未命中|太短|无效拖动|加载中|加载场景)/.test(text);
}
function renderSceneList(scenes, activeId) {
sceneList.innerHTML = "";
scenes.forEach((scene) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "scene-card" + (scene.id === activeId ? " active" : "");
btn.dataset.sceneId = scene.id;
const img = document.createElement("img");
img.src = scene.preview_url + "&t=" + Date.now();
img.alt = scene.name;
img.loading = "lazy";
const text = document.createElement("div");
text.className = "scene-text";
const name = document.createElement("div");
name.className = "scene-name";
name.textContent = scene.name;
const desc = document.createElement("div");
desc.className = "scene-desc";
desc.textContent = scene.description || "";
text.appendChild(name);
text.appendChild(desc);
btn.appendChild(img);
btn.appendChild(text);
btn.addEventListener("click", () => selectScene(scene.id));
sceneList.appendChild(btn);
});
}
async function selectScene(sceneId) {
if (sceneLoading || sceneId === currentSceneId) return;
setSceneLoading(true);
statusEl.textContent = "Loading scene / 加载场景中…";
try {
const data = await postJson("/api/select_scene", { scene_id: sceneId });
if (data.ok) {
if (data.message) statusEl.textContent = data.message;
} else {
statusEl.textContent = data.message || data.error || "Scene switch failed / 场景切换失败";
setSceneLoading(false);
}
} catch (err) {
statusEl.textContent = "Scene load failed / 场景加载失败";
setSceneLoading(false);
}
}
async function loadScenes() {
const res = await fetch("/api/scenes");
const data = await res.json();
currentSceneId = data.current_scene_id;
renderSceneList(data.scenes || [], currentSceneId);
}
async function refreshStatus() {
try {
const res = await fetch("/api/status");
const data = await res.json();
const nowLoading = !!data.loading;
if (nowLoading) {
setSceneLoading(true);
} else if (wasServerLoading || sceneLoading) {
setSceneLoading(false);
if (frame.naturalWidth) fitDisplaySize();
}
wasServerLoading = nowLoading;
if (typeof data.current_scene_id === "string" && data.current_scene_id !== currentSceneId) {
currentSceneId = data.current_scene_id;
document.querySelectorAll(".scene-card").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.sceneId === currentSceneId);
});
}
if (!isStickyStatus(statusEl.textContent)) {
statusEl.textContent = data.status || "";
}
pausedCb.checked = !!data.paused;
if (typeof data.force_magnitude === "number") {
forceSlider.value = data.force_magnitude;
forceVal.textContent = data.force_magnitude.toFixed(1);
}
if (typeof data.frame_version === "number" && data.frame_version !== lastFrameVersion) {
lastFrameVersion = data.frame_version;
refreshFrame(data.frame_version);
}
} catch (_) {}
}
function refreshFrame(frameVersion) {
const v = frameVersion != null ? frameVersion : lastFrameVersion;
frame.src = "/frame.jpg?v=" + v;
}
frame.addEventListener("load", fitDisplaySize);
window.addEventListener("resize", fitDisplaySize);
setInterval(refreshStatus, 200);
loadScenes().then(() => {
refreshStatus();
});
</script>
</body>
</html>
"""
def tensor_to_rgb(image_chw):
"""Convert (3, H, W) float tensor in [0, 1] to RGB uint8."""
img = image_chw.detach().clamp(0, 1).permute(1, 2, 0).cpu().numpy()
return (img * 255).astype(np.uint8)
def pixel_to_world_ray(camera, px, py):
"""Return (origin, direction) for a screen pixel. Both are CUDA float tensors."""
fx, fy = camera.Fx, camera.Fy
cx, cy = camera.Cx, camera.Cy
ray_cam = torch.tensor(
[(px - cx) / fx, (py - cy) / fy, 1.0],
dtype=torch.float32,
device="cuda",
)
ray_cam = ray_cam / torch.linalg.norm(ray_cam)
c2w = torch.linalg.inv(camera.world_view_transform)
rot = c2w[:3, :3]
origin = camera.camera_center
direction = rot @ ray_cam
direction = direction / torch.linalg.norm(direction)
return origin, direction
def pick_world_point_on_ray(world_positions, origin, direction, max_dist=0.15):
"""Pick world-space point on ray closest to simulated particles."""
oc = world_positions - origin.unsqueeze(0)
t = torch.sum(oc * direction.unsqueeze(0), dim=1)
t = torch.clamp(t, min=0.0)
closest = origin.unsqueeze(0) + t.unsqueeze(1) * direction.unsqueeze(0)
dist = torch.linalg.norm(world_positions - closest, dim=1)
valid = t > 0
if not torch.any(valid):
return None, None, None
dist = torch.where(valid, dist, torch.full_like(dist, float("inf")))
idx = int(torch.argmin(dist).item())
if float(dist[idx].item()) > max_dist:
return None, None, None
pick_t = float(t[idx].item())
return world_positions[idx].detach().cpu().numpy(), idx, pick_t
def compute_drag_world_force(cam_width, cam_height, camera, x0, y0, x1, y1, pick_t, force_magnitude):
"""Map screen drag to a world-space force along the drag direction."""
px0 = int(np.clip(x0, 0.0, 1.0) * (cam_width - 1))
py0 = int(np.clip(y0, 0.0, 1.0) * (cam_height - 1))
px1 = int(np.clip(x1, 0.0, 1.0) * (cam_width - 1))
py1 = int(np.clip(y1, 0.0, 1.0) * (cam_height - 1))
drag_screen = np.hypot(x1 - x0, y1 - y0)
if drag_screen < 0.01:
return None, "Drag too short / 拖动太短"
origin, dir0 = pixel_to_world_ray(camera, px0, py0)
_, dir1 = pixel_to_world_ray(camera, px1, py1)
p0 = origin + pick_t * dir0
p1 = origin + pick_t * dir1
drag_world = (p1 - p0).detach().cpu().numpy()
drag_norm = float(np.linalg.norm(drag_world))
if drag_norm < 1e-8:
return None, "Drag has no direction / 拖动方向无效"
direction = drag_world / drag_norm
magnitude = force_magnitude * drag_screen * 3.0
return (direction * magnitude).tolist(), None
class _ViewerHTTPHandler(BaseHTTPRequestHandler):
viewer = None
def log_message(self, format, *args):
return
def _send_json(self, payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_bytes(self, data: bytes, content_type: str) -> None:
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(data)
def _read_json_body(self):
length = int(self.headers.get("Content-Length", 0))
if length <= 0:
return {}
raw = self.rfile.read(length)
return json.loads(raw.decode("utf-8"))
def do_GET(self):
path = urlparse(self.path).path
viewer = self.viewer
if path in ("/", "/index.html"):
self._send_bytes(_INDEX_HTML.encode("utf-8"), "text/html; charset=utf-8")
elif path == "/frame.jpg":
with viewer._lock:
data = viewer._jpeg_bytes
if data is None:
self.send_error(503)
return
self._send_bytes(data, "image/jpeg")
elif path == "/api/status":
self._send_json(viewer.status_dict())
elif path == "/api/scenes":
self._send_json(viewer.scenes_dict())
elif path == "/api/scene_preview":
query = parse_qs(urlparse(self.path).query)
scene_id = (query.get("id") or [None])[0]
preview = viewer.get_scene_preview_bytes(scene_id)
if preview is None:
self.send_error(404)
return
data, content_type = preview
self._send_bytes(data, content_type)
else:
self.send_error(404)
def do_POST(self):
viewer = self.viewer
path = urlparse(self.path).path
try:
body = self._read_json_body()
except json.JSONDecodeError:
self.send_error(400)
return
if path == "/api/drag_release":
message = viewer.queue_drag_release(
float(body.get("x0", 0.5)),
float(body.get("y0", 0.5)),
float(body.get("x1", 0.5)),
float(body.get("y1", 0.5)),
)
self._send_json({"ok": True, "message": message})
elif path == "/api/pause":
viewer.paused = bool(body.get("paused", False))
self._send_json({"ok": True})
elif path == "/api/reset":
viewer.queue_reset()
self._send_json({"ok": True, "message": "Reset queued / 重置已排队"})
elif path == "/api/force":
viewer.set_force_magnitude(float(body.get("magnitude", viewer.force_magnitude)))
self._send_json({"ok": True})
elif path == "/api/select_scene":
scene_id = body.get("scene_id")
if not scene_id:
self._send_json({"ok": False, "error": "Missing scene_id"})
return
ok, message = viewer.queue_select_scene(str(scene_id))
self._send_json({"ok": ok, "message": message})
else:
self.send_error(404)
class InteractiveViewer:
"""Browser-based 2D image viewer (no 3D viewport)."""
def __init__(
self,
session,
session_factory,
scenes,
current_scene_id,
force_magnitude=1.0,
pick_radius=0.15,
impulse_radius=0.05,
host="0.0.0.0",
port=8080,
target_fps=15,
downsample=0.5,
white_bg=False,
):
self.session = session
self.session_factory = session_factory
self.scenes = scenes
self.scene_by_id = {s["id"]: s for s in scenes}
self.current_scene_id = current_scene_id
self.downsample = downsample
self.white_bg = white_bg
self.force_magnitude = self._clamp_force(force_magnitude)
self.pick_radius = pick_radius
self.impulse_radius = impulse_radius
self.host = host
self.port = port
self.target_fps = target_fps
self.paused = False
self.running = True
self.status = "Ready / 就绪"
self._loading_scene = False
self._lock = threading.Lock()
self._action_lock = threading.Lock()
self._pending_drag = None
self._pending_scene_id = None
self._burst_substeps = 0
self._reset_pending = False
self._jpeg_bytes = None
self._httpd = None
self._preview_cache = {}
self._frame_version = 0
def _clamp_force(self, value: float) -> float:
return float(np.clip(value, _FORCE_MIN, _FORCE_MAX))
def set_force_magnitude(self, value: float) -> None:
self.force_magnitude = self._clamp_force(value)
def queue_reset(self) -> None:
self._reset_pending = True
def queue_select_scene(self, scene_id: str) -> tuple[bool, str]:
if scene_id not in self.scene_by_id:
return False, f"Unknown scene / 未知场景: {scene_id}"
if scene_id == self.current_scene_id:
return False, "Already active / 已是当前场景"
with self._action_lock:
self._pending_scene_id = scene_id
return True, "Scene switch queued / 场景切换已排队"
def get_scene_preview_bytes(self, scene_id: str):
if not scene_id or scene_id not in self.scene_by_id:
return None
if scene_id in self._preview_cache:
return self._preview_cache[scene_id]
scene = self.scene_by_id[scene_id]
preview_path = scene.get("preview")
if not preview_path or not os.path.isfile(preview_path):
return None
mime, _ = mimetypes.guess_type(preview_path)
if not mime:
mime = "image/png"
with open(preview_path, "rb") as f:
data = f.read()
self._preview_cache[scene_id] = (data, mime)
return self._preview_cache[scene_id]
def scenes_dict(self):
return {
"current_scene_id": self.current_scene_id,
"scenes": [scene_public_view(s) for s in self.scenes],
}
def _reload_scene(self, scene_id: str) -> None:
scene = self.scene_by_id[scene_id]
self._loading_scene = True
self.status = f"Loading {scene['name']} / 加载中…"
print(f"Loading scene: {scene_id} ({scene['name']})")
try:
self.session = None
torch.cuda.empty_cache()
self.session = self.session_factory(scene)
self.current_scene_id = scene_id
self._burst_substeps = 0
self.status = f"Loaded: {scene['name']}"
print(f"Scene ready: {scene_id}")
except Exception as exc:
self.status = f"Scene load failed / 场景加载失败: {exc}"
print(f"Scene load failed: {scene_id}: {exc}")
finally:
self._loading_scene = False
def queue_drag_release(self, x0: float, y0: float, x1: float, y1: float) -> str:
with self._action_lock:
self._pending_drag = (x0, y0, x1, y1)
return "Drag queued / 拖动已排队"
def _process_drag_release(self, x0: float, y0: float, x1: float, y1: float) -> None:
camera = self.session.current_camera
w = self.session.cam_width
h = self.session.cam_height
px0 = int(np.clip(x0, 0.0, 1.0) * (w - 1))
py0 = int(np.clip(y0, 0.0, 1.0) * (h - 1))
origin, direction = pixel_to_world_ray(camera, px0, py0)
world_pos = self.session.get_world_positions()
picked, _, pick_t = pick_world_point_on_ray(
world_pos, origin, direction, max_dist=self.pick_radius
)
if picked is None:
self.status = f"Missed model / 未命中模型 ({px0}, {py0})"
return
force, err = compute_drag_world_force(
w, h, camera, x0, y0, x1, y1, pick_t, self.force_magnitude
)
if force is None:
self.status = err or "Invalid drag / 无效拖动"
return
self.session.apply_impulse_at_world(picked, force, radius=self.impulse_radius)
self._burst_substeps = self.session.substeps_per_frame
fx, fy, fz = force
self.status = (
f"Force ({fx:.2f}, {fy:.2f}, {fz:.2f}) / 施力 "
f"at ({picked[0]:.2f}, {picked[1]:.2f}, {picked[2]:.2f})"
)
def _process_pending(self) -> None:
scene_id = None
with self._action_lock:
if self._pending_scene_id is not None:
scene_id = self._pending_scene_id
self._pending_scene_id = None
self._pending_drag = None
self._reset_pending = False
if scene_id is not None:
self._reload_scene(scene_id)
return
if self._reset_pending:
self._reset_pending = False
self.session.reset_simulation()
self.status = "Simulation reset / 仿真已重置"
return
drag = None
with self._action_lock:
if self._pending_drag is not None:
drag = self._pending_drag
self._pending_drag = None
if drag is not None:
self._process_drag_release(*drag)
def status_dict(self):
scene = self.scene_by_id.get(self.current_scene_id, {})
return {
"status": self.status,
"paused": self.paused,
"loading": self._loading_scene,
"current_scene_id": self.current_scene_id,
"current_scene_name": scene.get("name", ""),
"force_magnitude": self.force_magnitude,
"force_min": _FORCE_MIN,
"force_max": _FORCE_MAX,
"frame_version": self._frame_version,
"time": float(self.session.mpm_solver.time) if self.session else 0.0,
}
def _encode_jpeg(self, rgb: np.ndarray) -> bytes:
buf = io.BytesIO()
Image.fromarray(rgb).save(buf, format="JPEG", quality=85)
return buf.getvalue()
def _start_http_server(self) -> None:
_ViewerHTTPHandler.viewer = self
self._httpd = ThreadingHTTPServer((self.host, self.port), _ViewerHTTPHandler)
thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
thread.start()
def run(self):
self._start_http_server()
display_host = "localhost" if self.host in ("0.0.0.0", "::") else self.host
print(f"2D viewer running at http://{display_host}:{self.port}")
print(" If remote: ssh -L 8080:localhost:8080 user@server")
print(f" Then open http://localhost:{self.port} in your browser")
print(" Drag & release to push / 拖动后松开施力")
frame_interval = 1.0 / max(self.target_fps, 1.0)
while self.running:
t0 = time.time()
self._process_pending()
if self._loading_scene or self.session is None:
time.sleep(0.05)
continue
n_substeps = 0
if self._burst_substeps > 0:
n_substeps = self._burst_substeps
self._burst_substeps = 0
elif not self.paused:
n_substeps = self.session.substeps_per_frame
if n_substeps > 0:
self.session.step(n_substeps=n_substeps)
rgb = tensor_to_rgb(self.session.render())
jpeg = self._encode_jpeg(rgb)
with self._lock:
self._jpeg_bytes = jpeg
self._frame_version += 1
if not self.status.startswith(
("Force", "Missed", "Drag", "Simulation", "Invalid", "Loading", "Loaded",
"施力", "未命中", "仿真", "无效", "太短", "加载")
):
self.status = f"t={self.session.mpm_solver.time:.4f}s"
elapsed = time.time() - t0
time.sleep(max(0.0, frame_interval - elapsed))