"""Extract quỹ đạo polyline từ System sau simulate(render=True) — MỚI 23/07/2026. Đúng logic render_png CLI đang dùng: ưu tiên history_cts (đã continuize dt=0.01), fallback history event-based; vectorize() → rvw[:, 0, :2]. Downsample còn ≤ max_points điểm/bi để JSON nhẹ (~10 KB/cú). 25/07/2026 (Production §2e): thêm ``min_disp`` OPT-IN để lọc bi đứng yên. ``continuize`` điền history cho MỌI bi kể cả bi không hề nhúc nhích → bàn full rack cho ra 200 điểm × 10 bi × 4 cú ≈ 250-350 KB JSON/response (đo bàn 3 bi chỉ ~10 KB/cú). Mặc định None = hành vi cũ y nguyên (đường v1 + CLI render_png KHÔNG được đổi một pixel). """ from __future__ import annotations def extract_trajectories(system, max_points: int = 200, min_disp: float | None = None) -> dict: """{ball_id: [[x, y], ...]} — chỉ gồm bi có quỹ đạo ≥ 2 điểm. min_disp: bỏ qua bi có ĐỘ DÀI ĐƯỜNG ĐI < min_disp (mét). Cố ý dùng độ dài đường đi chứ không phải dịch chuyển ròng đầu→cuối: bi bị đánh đi rồi dội băng về gần chỗ cũ có dịch chuyển ròng ~0 nhưng đó chính là quỹ đạo người dùng cần thấy. None (mặc định) = không lọc. """ import numpy as np out: dict = {} if system is None: return out for bid, ball in system.balls.items(): xy = None for hist in (getattr(ball, "history_cts", None), getattr(ball, "history", None)): if hist is None: continue try: rvw, _ss, _ts = hist.vectorize() except Exception: continue if rvw is not None and len(rvw) > 1: xy = rvw[:, 0, :2] break if xy is None: continue if min_disp is not None: # đo trên mảng ĐẦY ĐỦ, trước khi downsample path = float(np.linalg.norm(np.diff(xy, axis=0), axis=1).sum()) if path < min_disp: continue if len(xy) > max_points: idx = np.round(np.linspace(0, len(xy) - 1, max_points)).astype(int) xy = xy[idx] out[bid] = [[float(p[0]), float(p[1])] for p in xy] return out