Quaternion GUI calculator. Simple Q*Q no Q8A**Q stuff!
This commit is contained in:
parent
361fadaca2
commit
0f6f0a67c1
376
quaternions/quat_gui.py
Normal file
376
quaternions/quat_gui.py
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Quaternion GUI workbench + interactive 3D axes visualiser (NO normalisation).
|
||||||
|
|
||||||
|
|
||||||
|
- QA * QB is exactly ONE Hamilton product (no hidden extra maths)
|
||||||
|
- Plotting does NOT normalise the quaternion and does NOT do q*p*q̄ (no "two multiplies" for plotting)
|
||||||
|
- Still plots axes for "impure" cases, but overlays a warning on the graph
|
||||||
|
- Your rule: IMPURE if R >= 1e-6 (note: not abs(), exactly as stated)
|
||||||
|
- Shows a RED vector arrow for the vector part (i, j, k)
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
python -m pip install numpy matplotlib
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, messagebox
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("TkAgg")
|
||||||
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
|
||||||
|
|
||||||
|
EPS_IMPURE_R = 1e-6
|
||||||
|
EPS_NORM = 1e-12
|
||||||
|
|
||||||
|
|
||||||
|
class Quaternion:
|
||||||
|
__slots__ = ("w", "x", "y", "z")
|
||||||
|
|
||||||
|
def __init__(self, w: float, x: float, y: float, z: float):
|
||||||
|
self.w = float(w)
|
||||||
|
self.x = float(x)
|
||||||
|
self.y = float(y)
|
||||||
|
self.z = float(z)
|
||||||
|
|
||||||
|
def as_tuple(self):
|
||||||
|
return (self.w, self.x, self.y, self.z)
|
||||||
|
|
||||||
|
def norm2(self) -> float:
|
||||||
|
return self.w * self.w + self.x * self.x + self.y * self.y + self.z * self.z
|
||||||
|
|
||||||
|
def norm(self) -> float:
|
||||||
|
return math.sqrt(self.norm2())
|
||||||
|
|
||||||
|
def conjugate(self):
|
||||||
|
return Quaternion(self.w, -self.x, -self.y, -self.z)
|
||||||
|
|
||||||
|
def inverse(self):
|
||||||
|
n2 = self.norm2()
|
||||||
|
if n2 < EPS_NORM:
|
||||||
|
raise ZeroDivisionError("Quaternion norm is ~0; cannot invert.")
|
||||||
|
c = self.conjugate()
|
||||||
|
return Quaternion(c.w / n2, c.x / n2, c.y / n2, c.z / n2)
|
||||||
|
|
||||||
|
# "impure" if |R|>= 1e-6
|
||||||
|
def is_impure(self, eps_r=EPS_IMPURE_R) -> bool:
|
||||||
|
return self.w >= abs(eps_r)
|
||||||
|
|
||||||
|
def __add__(self, other):
|
||||||
|
return Quaternion(self.w + other.w, self.x + other.x, self.y + other.y, self.z + other.z)
|
||||||
|
|
||||||
|
def __sub__(self, other):
|
||||||
|
return Quaternion(self.w - other.w, self.x - other.x, self.y - other.y, self.z - other.z)
|
||||||
|
|
||||||
|
def __mul__(self, other):
|
||||||
|
# Hamilton product
|
||||||
|
w1, x1, y1, z1 = self.as_tuple()
|
||||||
|
w2, x2, y2, z2 = other.as_tuple()
|
||||||
|
return Quaternion(
|
||||||
|
w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2,
|
||||||
|
w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2,
|
||||||
|
w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2,
|
||||||
|
w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __truediv__(self, other):
|
||||||
|
return self * other.inverse()
|
||||||
|
|
||||||
|
|
||||||
|
def quat_to_matrix_no_normalise(q: Quaternion) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Standard quaternion->matrix formula, but DOES NOT normalise q.
|
||||||
|
|
||||||
|
If q is not unit, the resulting matrix will generally not be orthonormal.
|
||||||
|
That's useful here for visualising intermediates without 'helpful' coercion.
|
||||||
|
"""
|
||||||
|
w, x, y, z = q.w, q.x, q.y, q.z
|
||||||
|
|
||||||
|
r00 = 1.0 - 2.0 * (y * y + z * z)
|
||||||
|
r01 = 2.0 * (x * y - w * z)
|
||||||
|
r02 = 2.0 * (x * z + w * y)
|
||||||
|
|
||||||
|
r10 = 2.0 * (x * y + w * z)
|
||||||
|
r11 = 1.0 - 2.0 * (x * x + z * z)
|
||||||
|
r12 = 2.0 * (y * z - w * x)
|
||||||
|
|
||||||
|
r20 = 2.0 * (x * z - w * y)
|
||||||
|
r21 = 2.0 * (y * z + w * x)
|
||||||
|
r22 = 1.0 - 2.0 * (x * x + y * y)
|
||||||
|
|
||||||
|
return np.array([[r00, r01, r02],
|
||||||
|
[r10, r11, r12],
|
||||||
|
[r20, r21, r22]], dtype=float)
|
||||||
|
|
||||||
|
|
||||||
|
class App(tk.Tk):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title("Quaternion Workbench + Axes Visualiser (raw quaternion)")
|
||||||
|
self.geometry("1180x700")
|
||||||
|
|
||||||
|
# QA, QB default to identity
|
||||||
|
self.qa_vars = [
|
||||||
|
tk.StringVar(value="1.0"),
|
||||||
|
tk.StringVar(value="0.0"),
|
||||||
|
tk.StringVar(value="0.0"),
|
||||||
|
tk.StringVar(value="0.0"),
|
||||||
|
]
|
||||||
|
self.qb_vars = [
|
||||||
|
tk.StringVar(value="1.0"),
|
||||||
|
tk.StringVar(value="0.0"),
|
||||||
|
tk.StringVar(value="0.0"),
|
||||||
|
tk.StringVar(value="0.0"),
|
||||||
|
]
|
||||||
|
self.qr_vars = [
|
||||||
|
tk.StringVar(value=""),
|
||||||
|
tk.StringVar(value=""),
|
||||||
|
tk.StringVar(value=""),
|
||||||
|
tk.StringVar(value=""),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.status_var = tk.StringVar(value="Ready.")
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._build_plot()
|
||||||
|
self._update_plot_from_result(None)
|
||||||
|
|
||||||
|
# ---------------- UI ----------------
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
outer = ttk.Frame(self, padding=10)
|
||||||
|
outer.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
left = ttk.Frame(outer)
|
||||||
|
left.pack(side="left", fill="y", padx=(0, 12))
|
||||||
|
|
||||||
|
right = ttk.Frame(outer)
|
||||||
|
right.pack(side="right", fill="both", expand=True)
|
||||||
|
|
||||||
|
qa_frame = ttk.LabelFrame(left, text="Quaternion QA (R, i, j, k)", padding=10)
|
||||||
|
qb_frame = ttk.LabelFrame(left, text="Quaternion QB (R, i, j, k)", padding=10)
|
||||||
|
btn_frame = ttk.LabelFrame(left, text="Operations", padding=10)
|
||||||
|
qr_frame = ttk.LabelFrame(left, text="Result QR (R, i, j, k)", padding=10)
|
||||||
|
|
||||||
|
qa_frame.pack(fill="x", pady=(0, 10))
|
||||||
|
qb_frame.pack(fill="x", pady=(0, 10))
|
||||||
|
btn_frame.pack(fill="x", pady=(0, 10))
|
||||||
|
qr_frame.pack(fill="x", pady=(0, 10))
|
||||||
|
|
||||||
|
self._make_quat_row(qa_frame, self.qa_vars)
|
||||||
|
ttk.Button(qa_frame, text="Conjugate QA", command=self.on_conjugate_qa).grid(
|
||||||
|
row=2, column=0, columnspan=4, sticky="ew", pady=(8, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._make_quat_row(qb_frame, self.qb_vars)
|
||||||
|
self._make_quat_row(qr_frame, self.qr_vars, readonly=True)
|
||||||
|
|
||||||
|
ttk.Button(btn_frame, text="QA + QB", command=lambda: self.on_op("+")).grid(row=0, column=0, sticky="ew", padx=2, pady=2)
|
||||||
|
ttk.Button(btn_frame, text="QA - QB", command=lambda: self.on_op("-")).grid(row=0, column=1, sticky="ew", padx=2, pady=2)
|
||||||
|
ttk.Button(btn_frame, text="QA * QB", command=lambda: self.on_op("*")).grid(row=1, column=0, sticky="ew", padx=2, pady=2)
|
||||||
|
ttk.Button(btn_frame, text="QA / QB", command=lambda: self.on_op("/")).grid(row=1, column=1, sticky="ew", padx=2, pady=2)
|
||||||
|
|
||||||
|
ttk.Separator(btn_frame, orient="horizontal").grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 10))
|
||||||
|
|
||||||
|
ttk.Button(btn_frame, text="Copy QR → QA", command=self.copy_qr_to_qa).grid(row=3, column=0, sticky="ew", padx=2, pady=2)
|
||||||
|
ttk.Button(btn_frame, text="Copy QR → QB", command=self.copy_qr_to_qb).grid(row=3, column=1, sticky="ew", padx=2, pady=2)
|
||||||
|
|
||||||
|
ttk.Button(btn_frame, text="Clear Result", command=self.clear_result).grid(row=4, column=0, columnspan=2, sticky="ew", padx=2, pady=(8, 2))
|
||||||
|
|
||||||
|
for c in range(2):
|
||||||
|
btn_frame.columnconfigure(c, weight=1)
|
||||||
|
|
||||||
|
status = ttk.Label(left, textvariable=self.status_var, wraplength=360, foreground="#444")
|
||||||
|
status.pack(fill="x", pady=(6, 0))
|
||||||
|
|
||||||
|
plot_frame = ttk.LabelFrame(
|
||||||
|
right,
|
||||||
|
text="3D View (interactive: drag to rotate, scroll to zoom)",
|
||||||
|
padding=8,
|
||||||
|
)
|
||||||
|
plot_frame.pack(fill="both", expand=True)
|
||||||
|
self.plot_frame = plot_frame
|
||||||
|
|
||||||
|
def _make_quat_row(self, parent, vars4, readonly=False):
|
||||||
|
labels = ["R", "i", "j", "k"]
|
||||||
|
for idx, lab in enumerate(labels):
|
||||||
|
ttk.Label(parent, text=lab).grid(row=0, column=idx, sticky="w")
|
||||||
|
|
||||||
|
for idx, var in enumerate(vars4):
|
||||||
|
ent = ttk.Entry(parent, textvariable=var, width=10)
|
||||||
|
if readonly:
|
||||||
|
ent.state(["readonly"])
|
||||||
|
ent.grid(row=1, column=idx, sticky="ew", padx=2)
|
||||||
|
parent.columnconfigure(idx, weight=1)
|
||||||
|
|
||||||
|
# ---------------- Plot ----------------
|
||||||
|
|
||||||
|
def _build_plot(self):
|
||||||
|
fig = Figure(figsize=(6.4, 5.4), dpi=100)
|
||||||
|
ax = fig.add_subplot(111, projection="3d")
|
||||||
|
self.fig = fig
|
||||||
|
self.ax = ax
|
||||||
|
|
||||||
|
canvas = FigureCanvasTkAgg(fig, master=self.plot_frame)
|
||||||
|
canvas.draw()
|
||||||
|
canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
toolbar = NavigationToolbar2Tk(canvas, self.plot_frame)
|
||||||
|
toolbar.update()
|
||||||
|
|
||||||
|
self.canvas = canvas
|
||||||
|
|
||||||
|
def _draw_axes(self, ax, origin, basis, label_prefix="A", alpha=1.0):
|
||||||
|
o = origin
|
||||||
|
for idx, name in enumerate(["x", "y", "z"]):
|
||||||
|
d = basis[:, idx]
|
||||||
|
ax.quiver(o[0], o[1], o[2], d[0], d[1], d[2], length=1.0, normalize=True, alpha=alpha)
|
||||||
|
tip = o + d
|
||||||
|
ax.text(tip[0], tip[1], tip[2], f"{label_prefix}{name}", alpha=alpha)
|
||||||
|
|
||||||
|
def _finalise_axes(self, ax):
|
||||||
|
ax.set_xlim(-1.2, 1.2)
|
||||||
|
ax.set_ylim(-1.2, 1.2)
|
||||||
|
ax.set_zlim(-1.2, 1.2)
|
||||||
|
ax.set_xlabel("X")
|
||||||
|
ax.set_ylabel("Y")
|
||||||
|
ax.set_zlabel("Z")
|
||||||
|
ax.view_init(elev=20, azim=35)
|
||||||
|
|
||||||
|
def _update_plot_from_result(self, q: Quaternion | None):
|
||||||
|
"""
|
||||||
|
Plotting rules:
|
||||||
|
- DO NOT normalise q for plotting
|
||||||
|
- DO NOT do q*p*q̄ (no quaternion multiplies needed for the plot)
|
||||||
|
- Always draw R axes, even in 'impure' cases
|
||||||
|
- Overlay warning when (rule) R >= 1e-6
|
||||||
|
"""
|
||||||
|
ax = self.ax
|
||||||
|
ax.clear()
|
||||||
|
|
||||||
|
# World axes
|
||||||
|
self._draw_axes(ax, origin=np.zeros(3), basis=np.eye(3), label_prefix="W", alpha=0.25)
|
||||||
|
|
||||||
|
if q is None:
|
||||||
|
ax.set_title("No result quaternion yet")
|
||||||
|
self._finalise_axes(ax)
|
||||||
|
self.canvas.draw()
|
||||||
|
return
|
||||||
|
|
||||||
|
# RED vector arrow for vector part (i, j, k)
|
||||||
|
v = np.array([q.x, q.y, q.z], dtype=float)
|
||||||
|
if float(np.linalg.norm(v)) > 0:
|
||||||
|
ax.quiver(
|
||||||
|
0, 0, 0,
|
||||||
|
v[0], v[1], v[2],
|
||||||
|
length=1.0,
|
||||||
|
#normalize=True,
|
||||||
|
normalize=False,
|
||||||
|
alpha=0.95,
|
||||||
|
color="red",
|
||||||
|
)
|
||||||
|
ax.text(v[0], v[1], v[2], "v=(i,j,k)", alpha=0.95)
|
||||||
|
else:
|
||||||
|
ax.text(0, 0, 0, "v=(0,0,0)", alpha=0.95)
|
||||||
|
|
||||||
|
# Degenerate quaternion (can't form meaningful axes)
|
||||||
|
if q.norm() < EPS_NORM:
|
||||||
|
ax.text2D(0.03, 0.95, "⚠ Quaternion norm ~0 — cannot show axes", transform=ax.transAxes)
|
||||||
|
ax.set_title("Degenerate quaternion")
|
||||||
|
self._finalise_axes(ax)
|
||||||
|
self.canvas.draw()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Result axes from raw quaternion -> matrix (no normalisation)
|
||||||
|
Rm = quat_to_matrix_no_normalise(q)
|
||||||
|
basis = np.column_stack([Rm[:, 0], Rm[:, 1], Rm[:, 2]])
|
||||||
|
self._draw_axes(ax, origin=np.zeros(3), basis=basis, label_prefix="R", alpha=0.9)
|
||||||
|
|
||||||
|
# Warning overlay per your rule: impure if R >= 1e-6
|
||||||
|
if q.is_impure(EPS_IMPURE_R):
|
||||||
|
ax.text2D(
|
||||||
|
0.03, 0.95,
|
||||||
|
"⚠ IMPURE (rule: |R| ≥ 1e-6)\nAxes drawn from RAW quaternion (no normalisation)",
|
||||||
|
transform=ax.transAxes
|
||||||
|
)
|
||||||
|
ax.set_title("Result axes + IMPURE warning")
|
||||||
|
else:
|
||||||
|
ax.set_title("Result axes (raw quaternion; no normalisation)")
|
||||||
|
|
||||||
|
self._finalise_axes(ax)
|
||||||
|
self.canvas.draw()
|
||||||
|
|
||||||
|
# ---------------- Actions ----------------
|
||||||
|
|
||||||
|
def parse_quat(self, vars4, name: str) -> Quaternion:
|
||||||
|
try:
|
||||||
|
vals = [float(v.get().strip()) for v in vars4]
|
||||||
|
return Quaternion(*vals)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"{name}: please enter numeric values for R, i, j, k.")
|
||||||
|
|
||||||
|
def set_result(self, q: Quaternion):
|
||||||
|
for var, val in zip(self.qr_vars, q.as_tuple()):
|
||||||
|
var.set(f"{val:.12g}")
|
||||||
|
self._update_plot_from_result(q)
|
||||||
|
|
||||||
|
def clear_result(self):
|
||||||
|
for var in self.qr_vars:
|
||||||
|
var.set("")
|
||||||
|
self.status_var.set("Ready.")
|
||||||
|
self._update_plot_from_result(None)
|
||||||
|
|
||||||
|
def copy_qr_to_qa(self):
|
||||||
|
if not self.qr_vars[0].get().strip():
|
||||||
|
return
|
||||||
|
for src, dst in zip(self.qr_vars, self.qa_vars):
|
||||||
|
dst.set(src.get())
|
||||||
|
self.status_var.set("Copied QR → QA")
|
||||||
|
|
||||||
|
def copy_qr_to_qb(self):
|
||||||
|
if not self.qr_vars[0].get().strip():
|
||||||
|
return
|
||||||
|
for src, dst in zip(self.qr_vars, self.qb_vars):
|
||||||
|
dst.set(src.get())
|
||||||
|
self.status_var.set("Copied QR → QB")
|
||||||
|
|
||||||
|
def on_conjugate_qa(self):
|
||||||
|
try:
|
||||||
|
qa = self.parse_quat(self.qa_vars, "QA")
|
||||||
|
qc = qa.conjugate()
|
||||||
|
for var, val in zip(self.qa_vars, qc.as_tuple()):
|
||||||
|
var.set(f"{val:.12g}")
|
||||||
|
self.status_var.set("QA conjugated.")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Error", str(e))
|
||||||
|
|
||||||
|
def on_op(self, op: str):
|
||||||
|
try:
|
||||||
|
qa = self.parse_quat(self.qa_vars, "QA")
|
||||||
|
qb = self.parse_quat(self.qb_vars, "QB")
|
||||||
|
|
||||||
|
if op == "+":
|
||||||
|
qr = qa + qb
|
||||||
|
elif op == "-":
|
||||||
|
qr = qa - qb
|
||||||
|
elif op == "*":
|
||||||
|
qr = qa * qb
|
||||||
|
elif op == "/":
|
||||||
|
qr = qa / qb
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown operation")
|
||||||
|
|
||||||
|
self.set_result(qr)
|
||||||
|
self.status_var.set(f"Computed QR = QA {op} QB")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Error", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
App().mainloop()
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user