"""六組雙擺重疊比較動畫(僅使用 Python 標準函式庫)。""" from __future__ import annotations import math import re import time import tkinter as tk from collections import deque from dataclasses import dataclass from tkinter import ttk # 六組雙擺共用的固定物理參數(SI 單位) GRAVITY = 9.81 LENGTH_1 = 1.0 LENGTH_2 = 1.0 MASS_1 = 1.0 MASS_2 = 1.0 PHYSICS_STEP = 1.0 / 240.0 FRAME_DELAY_MS = 16 TRACE_MAX_POINTS = 400 TRACE_SAMPLE_EVERY_FRAMES = 3 PIVOT_RADIUS = 4.0 BOB_RADIUS = 7.0 CANVAS_BACKGROUND = "#101827" RUN_COLORS = ( "#00E5FF", # 青 "#4D96FF", # 藍 "#00E676", # 綠 "#FFD600", # 黃 "#C77DFF", # 紫 "#FF9100", # 橙 ) # Tkinter Canvas 不支援真正的 alpha;以下數值會先把顏色與背景混合, # 產生等效透明度。暖色降低權重,避免黃、橙覆蓋其他軌跡。 TRACE_OPACITY = (0.70, 0.82, 0.70, 0.52, 0.82, 0.60) ROD_OPACITY = (0.86, 0.92, 0.86, 0.72, 0.90, 0.70) # 先畫視覺較強的暖色,最後畫較不刺眼的冷色,避免橙色永遠位於最上層。 DRAW_ORDER = (3, 5, 2, 0, 4, 1) # 預設使用相近的第一擺角度,方便觀察混沌系統如何逐漸分歧。 INITIAL_ANGLES = ( (120.00, -10.00), (120.01, -10.00), (120.02, -10.00), (120.03, -10.00), (120.04, -10.00), (120.05, -10.00), ) State = tuple[float, float, float, float] def blend_color(foreground: str, background: str, opacity: float) -> str: """將前景色與背景色混合,模擬 Tkinter Canvas 不具備的透明度。""" foreground_rgb = [int(foreground[index : index + 2], 16) for index in (1, 3, 5)] background_rgb = [int(background[index : index + 2], 16) for index in (1, 3, 5)] mixed = [ round(front * opacity + back * (1.0 - opacity)) for front, back in zip(foreground_rgb, background_rgb) ] return "#" + "".join(f"{channel:02X}" for channel in mixed) def derivatives(state: State) -> State: """回傳 (theta1_dot, omega1_dot, theta2_dot, omega2_dot)。""" theta_1, omega_1, theta_2, omega_2 = state angle_delta = theta_1 - theta_2 common = 2.0 * MASS_1 + MASS_2 - MASS_2 * math.cos(2.0 * angle_delta) alpha_1 = ( -GRAVITY * (2.0 * MASS_1 + MASS_2) * math.sin(theta_1) - MASS_2 * GRAVITY * math.sin(theta_1 - 2.0 * theta_2) - 2.0 * MASS_2 * math.sin(angle_delta) * ( omega_2 * omega_2 * LENGTH_2 + omega_1 * omega_1 * LENGTH_1 * math.cos(angle_delta) ) ) / (LENGTH_1 * common) alpha_2 = ( 2.0 * math.sin(angle_delta) * ( omega_1 * omega_1 * LENGTH_1 * (MASS_1 + MASS_2) + GRAVITY * (MASS_1 + MASS_2) * math.cos(theta_1) + omega_2 * omega_2 * LENGTH_2 * MASS_2 * math.cos(angle_delta) ) ) / (LENGTH_2 * common) return omega_1, alpha_1, omega_2, alpha_2 def rk4_step(state: State, dt: float) -> State: """用四階 Runge-Kutta 積分一步。""" k1 = derivatives(state) k2 = derivatives(tuple(value + 0.5 * dt * slope for value, slope in zip(state, k1))) k3 = derivatives(tuple(value + 0.5 * dt * slope for value, slope in zip(state, k2))) k4 = derivatives(tuple(value + dt * slope for value, slope in zip(state, k3))) return tuple( value + dt * (a + 2.0 * b + 2.0 * c + d) / 6.0 for value, a, b, c, d in zip(state, k1, k2, k3, k4) ) @dataclass class PendulumRun: color: str angle_1: tk.StringVar angle_2: tk.StringVar state: State trace: deque[tuple[float, float]] class MultiDoublePendulumApp: def __init__(self, root: tk.Tk) -> None: self.root = root self.root.title("六組雙擺重疊比較") self.root.minsize(880, 650) self.running = False self.inputs_dirty = False self.simulation_time = 0.0 self.accumulator = 0.0 self.last_clock = time.perf_counter() self.trace_frame_counter = 0 self.time_text = tk.StringVar(value="時間:0.00 s") self.status_text = tk.StringVar(value="已暫停") self.runs: list[PendulumRun] = [] for color, (angle_1, angle_2) in zip(RUN_COLORS, INITIAL_ANGLES): self.runs.append( PendulumRun( color=color, angle_1=tk.StringVar(value=f"{angle_1:.5f}"), angle_2=tk.StringVar(value=f"{angle_2:.5f}"), state=(math.radians(angle_1), 0.0, math.radians(angle_2), 0.0), trace=deque(maxlen=TRACE_MAX_POINTS), ) ) self._build_ui() self.reset() self.root.after(FRAME_DELAY_MS, self._animate) def _build_ui(self) -> None: style = ttk.Style() style.configure("Title.TLabel", font=("Microsoft JhengHei UI", 17, "bold")) style.configure("Info.TLabel", font=("Microsoft JhengHei UI", 10)) outer = ttk.Frame(self.root, padding=14) outer.pack(fill=tk.BOTH, expand=True) ttk.Label(outer, text="六組雙擺重疊比較", style="Title.TLabel").pack(anchor=tk.W) ttk.Label( outer, text=( "固定:L₁ = L₂ = 1.0 m m₁ = m₂ = 1.0 kg g = 9.81 m/s²\n" "每組只調整兩個初始角度;輸入精度 0.00001°" ), style="Info.TLabel", ).pack(anchor=tk.W, pady=(2, 8)) input_grid = ttk.Frame(outer) input_grid.pack(fill=tk.X) input_grid.columnconfigure(0, weight=1) input_grid.columnconfigure(1, weight=1) validate_number = (self.root.register(self._validate_decimal_text), "%P") for index, run in enumerate(self.runs, start=1): cell = ttk.Frame(input_grid) cell.grid( row=(index - 1) // 2, column=(index - 1) % 2, sticky=tk.EW, padx=(0, 14) if index % 2 else (14, 0), pady=3, ) cell.columnconfigure(3, weight=1) cell.columnconfigure(5, weight=1) swatch = tk.Canvas( cell, width=14, height=14, background=self.root.cget("background"), highlightthickness=0, ) swatch.create_rectangle(1, 1, 13, 13, fill=run.color, outline="#374151") swatch.grid(row=0, column=0, sticky=tk.W, padx=(0, 4)) ttk.Label(cell, text=f"雙擺 {index}", width=7).grid( row=0, column=1, sticky=tk.W, padx=(0, 5) ) ttk.Label(cell, text="θ₁").grid(row=0, column=2, padx=(0, 3)) entry_1 = ttk.Entry( cell, textvariable=run.angle_1, width=12, validate="key", validatecommand=validate_number, ) entry_1.grid(row=0, column=3, sticky=tk.EW, padx=(0, 8)) ttk.Label(cell, text="θ₂").grid(row=0, column=4, padx=(0, 3)) entry_2 = ttk.Entry( cell, textvariable=run.angle_2, width=12, validate="key", validatecommand=validate_number, ) entry_2.grid(row=0, column=5, sticky=tk.EW) run.angle_1.trace_add("write", self._mark_inputs_dirty) run.angle_2.trace_add("write", self._mark_inputs_dirty) entry_1.bind("", lambda _event: self.reset()) entry_2.bind("", lambda _event: self.reset()) action_row = ttk.Frame(outer) action_row.pack(fill=tk.X, pady=8) self.play_button = ttk.Button(action_row, text="播放", command=self.toggle_play) self.play_button.pack(side=tk.LEFT) ttk.Button(action_row, text="重設", command=self.reset).pack(side=tk.LEFT, padx=8) ttk.Label(action_row, textvariable=self.status_text).pack(side=tk.LEFT, padx=(8, 0)) ttk.Label(action_row, textvariable=self.time_text).pack(side=tk.RIGHT) self.canvas = tk.Canvas( outer, background=CANVAS_BACKGROUND, highlightthickness=0, takefocus=True, ) self.canvas.pack(fill=tk.BOTH, expand=True) self.canvas.bind("", self._canvas_resized) self.root.bind("", lambda _event: self.toggle_play()) self.root.bind("", lambda _event: self.reset()) @staticmethod def _validate_decimal_text(proposed: str) -> bool: if proposed in {"", "+", "-", ".", "+.", "-."}: return True return re.fullmatch(r"[+-]?(?:\d+(?:\.\d{0,5})?|\.\d{1,5})", proposed) is not None def _mark_inputs_dirty(self, *_args: object) -> None: self.inputs_dirty = True def _read_angles(self) -> list[tuple[float, float]] | None: angles: list[tuple[float, float]] = [] try: for run in self.runs: angle_1 = float(run.angle_1.get()) angle_2 = float(run.angle_2.get()) if not math.isfinite(angle_1) or not math.isfinite(angle_2): raise ValueError angles.append((angle_1, angle_2)) except ValueError: self.status_text.set("請輸入有效角度(小數最多 5 位)") return None return angles def toggle_play(self) -> None: if not self.running and self.inputs_dirty and not self.reset(): return self.running = not self.running self.last_clock = time.perf_counter() self.accumulator = 0.0 self.play_button.configure(text="暫停" if self.running else "播放") self.status_text.set("模擬中" if self.running else "已暫停") def reset(self) -> bool: angles = self._read_angles() if angles is None: return False self.running = False for run, (angle_1, angle_2) in zip(self.runs, angles): run.angle_1.set(f"{angle_1:.5f}") run.angle_2.set(f"{angle_2:.5f}") run.state = (math.radians(angle_1), 0.0, math.radians(angle_2), 0.0) run.trace.clear() self.inputs_dirty = False self.simulation_time = 0.0 self.accumulator = 0.0 self.trace_frame_counter = 0 self.last_clock = time.perf_counter() if hasattr(self, "play_button"): self.play_button.configure(text="播放") self.status_text.set("已暫停") self.time_text.set("時間:0.00 s") self.draw() return True def _animate(self) -> None: now = time.perf_counter() frame_time = min(now - self.last_clock, 0.05) self.last_clock = now if self.running: self.accumulator += frame_time while self.accumulator >= PHYSICS_STEP: for run in self.runs: run.state = rk4_step(run.state, PHYSICS_STEP) self.simulation_time += PHYSICS_STEP self.accumulator -= PHYSICS_STEP self.trace_frame_counter += 1 if self.trace_frame_counter >= TRACE_SAMPLE_EVERY_FRAMES: origin, scale = self._view_geometry() for run in self.runs: _, second_bob = self._positions(run.state, origin, scale) run.trace.append(second_bob) self.trace_frame_counter = 0 self.time_text.set(f"時間:{self.simulation_time:.2f} s") self.draw() self.root.after(FRAME_DELAY_MS, self._animate) def _view_geometry(self) -> tuple[tuple[float, float], float]: width = max(self.canvas.winfo_width(), 1) height = max(self.canvas.winfo_height(), 1) origin = (width / 2.0, height / 2.0) total_length = LENGTH_1 + LENGTH_2 available_width = max(width - 70.0, 1.0) available_height = max(height - 70.0, 1.0) scale = min( available_width / (2.0 * total_length), available_height / (2.0 * total_length), ) return origin, scale @staticmethod def _positions( state: State, origin: tuple[float, float], scale: float, ) -> tuple[tuple[float, float], tuple[float, float]]: origin_x, origin_y = origin theta_1, _, theta_2, _ = state x1 = origin_x + scale * LENGTH_1 * math.sin(theta_1) y1 = origin_y + scale * LENGTH_1 * math.cos(theta_1) x2 = x1 + scale * LENGTH_2 * math.sin(theta_2) y2 = y1 + scale * LENGTH_2 * math.cos(theta_2) return (x1, y1), (x2, y2) def _canvas_resized(self, _event: tk.Event[tk.Misc]) -> None: for run in self.runs: run.trace.clear() self.trace_frame_counter = 0 self.draw() def draw(self) -> None: if not hasattr(self, "canvas"): return self.canvas.delete("all") origin, scale = self._view_geometry() origin_x, origin_y = origin # 先畫軌跡,讓桿和質點保持清楚。 for index in DRAW_ORDER: run = self.runs[index] if len(run.trace) > 1: flat_trace = [coordinate for point in run.trace for coordinate in point] self.canvas.create_line( *flat_trace, fill=blend_color(run.color, CANVAS_BACKGROUND, TRACE_OPACITY[index]), width=2, joinstyle=tk.ROUND, ) # 再畫六組目前狀態;顏色與上方輸入列一致。 for index in DRAW_ORDER: run = self.runs[index] first_bob, second_bob = self._positions(run.state, origin, scale) x1, y1 = first_bob x2, y2 = second_bob self.canvas.create_line( origin_x, origin_y, x1, y1, x2, y2, fill=blend_color(run.color, CANVAS_BACKGROUND, ROD_OPACITY[index]), width=2, ) for x, y in (first_bob, second_bob): self.canvas.create_oval( x - BOB_RADIUS, y - BOB_RADIUS, x + BOB_RADIUS, y + BOB_RADIUS, fill=run.color, outline="#F2F6FC", width=1, ) self.canvas.create_oval( origin_x - PIVOT_RADIUS, origin_y - PIVOT_RADIUS, origin_x + PIVOT_RADIUS, origin_y + PIVOT_RADIUS, fill="#F6C85F", outline="", ) def main() -> None: root = tk.Tk() MultiDoublePendulumApp(root) root.mainloop() if __name__ == "__main__": main()