offviewer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- offviewer/.offcheck.py.swp +0 -0
- offviewer/__init__.py +5 -0
- offviewer/offcheck.py +869 -0
- offviewer/offviewer.py +1908 -0
- offviewer-0.1.0.dist-info/METADATA +49 -0
- offviewer-0.1.0.dist-info/RECORD +8 -0
- offviewer-0.1.0.dist-info/WHEEL +4 -0
- offviewer-0.1.0.dist-info/entry_points.txt +4 -0
offviewer/offviewer.py
ADDED
|
@@ -0,0 +1,1908 @@
|
|
|
1
|
+
"""This script can be used to browse directories with OFF files.
|
|
2
|
+
|
|
3
|
+
It shows a grid of 2D representations of the 3D objects and can show some prooperties of the
|
|
4
|
+
polyhedra.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import math
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pygame
|
|
12
|
+
from pygame.locals import *
|
|
13
|
+
import moderngl
|
|
14
|
+
import threading
|
|
15
|
+
import csv
|
|
16
|
+
import re
|
|
17
|
+
import tkinter as tk
|
|
18
|
+
#from numba import njit
|
|
19
|
+
from tkinter import simpledialog, messagebox
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
|
|
22
|
+
from offviewer.offcheck import verify_off_logic
|
|
23
|
+
|
|
24
|
+
# Configuration
|
|
25
|
+
AUTO_ROTATION_SPEED = -0.0002
|
|
26
|
+
SORT_TOLERANCE = 0.0001
|
|
27
|
+
|
|
28
|
+
# Thread-safe signals for background analysis
|
|
29
|
+
bg_csv_ready = [False]
|
|
30
|
+
bg_csv_path = [None]
|
|
31
|
+
bg_run_id = [0]
|
|
32
|
+
|
|
33
|
+
def format_sort_value(val):
|
|
34
|
+
if val is None or val == "":
|
|
35
|
+
return ""
|
|
36
|
+
try:
|
|
37
|
+
f = float(val)
|
|
38
|
+
if f.is_integer():
|
|
39
|
+
return str(int(f))
|
|
40
|
+
return f"{f:.5g}"
|
|
41
|
+
except (ValueError, TypeError):
|
|
42
|
+
return str(val)
|
|
43
|
+
|
|
44
|
+
def parse_color(parts, default_color=[178, 204, 230, 255]):
|
|
45
|
+
if len(parts) == 0:
|
|
46
|
+
return default_color
|
|
47
|
+
try:
|
|
48
|
+
color = []
|
|
49
|
+
for x in parts[:4]:
|
|
50
|
+
val = float(x)
|
|
51
|
+
color.append(val)
|
|
52
|
+
if len(color) < 4:
|
|
53
|
+
color.append(255.0)
|
|
54
|
+
if any(c > 1.0 for c in color[:3]):
|
|
55
|
+
color = [c / 255.0 for c in color]
|
|
56
|
+
return color
|
|
57
|
+
except:
|
|
58
|
+
return default_color
|
|
59
|
+
|
|
60
|
+
#@njit(cache=True)
|
|
61
|
+
def seg_intersect(A, B, C, D):
|
|
62
|
+
denom = (B[0] - A[0]) * (D[1] - C[1]) - (B[1] - A[1]) * (D[0] - C[0])
|
|
63
|
+
if abs(denom) < 1e-9:
|
|
64
|
+
return None
|
|
65
|
+
t = ((C[0] - A[0]) * (D[1] - C[1]) - (C[1] - A[1]) * (D[0] - C[0])) / denom
|
|
66
|
+
u = ((C[0] - A[0]) * (B[1] - A[1]) - (C[1] - A[1]) * (B[0] - A[0])) / denom
|
|
67
|
+
if 1e-9 < t < 1.0 - 1e-9 and 1e-9 < u < 1.0 - 1e-9:
|
|
68
|
+
return t, u
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
#@njit(cache=True)
|
|
72
|
+
def get_crossing_count(point, poly_2d):
|
|
73
|
+
x, y = point
|
|
74
|
+
crossings = 0
|
|
75
|
+
n = len(poly_2d)
|
|
76
|
+
for i in range(n):
|
|
77
|
+
p1 = poly_2d[i]
|
|
78
|
+
p2 = poly_2d[(i + 1) % n]
|
|
79
|
+
if ((p1[1] <= y and p2[1] > y) or (p2[1] <= y and p1[1] > y)):
|
|
80
|
+
if x < p1[0] + (y - p1[1]) * (p2[0] - p1[0]) / (p2[1] - p1[1]):
|
|
81
|
+
crossings += 1
|
|
82
|
+
return crossings
|
|
83
|
+
|
|
84
|
+
def triangulate_simple_polygon(indices, verts_2d):
|
|
85
|
+
n = len(indices)
|
|
86
|
+
if n < 3:
|
|
87
|
+
return []
|
|
88
|
+
if n == 3:
|
|
89
|
+
return [indices]
|
|
90
|
+
|
|
91
|
+
poly = list(indices)
|
|
92
|
+
triangles = []
|
|
93
|
+
|
|
94
|
+
def is_ccw(p1, p2, p3):
|
|
95
|
+
return (p2[0] - p1[0]) * (p3[1] - p1[1]) - (p3[0] - p1[0]) * (p2[1] - p1[1]) > 0
|
|
96
|
+
|
|
97
|
+
def is_inside_triangle(p, a, b, c):
|
|
98
|
+
v0 = np.array(c) - np.array(a)
|
|
99
|
+
v1 = np.array(b) - np.array(a)
|
|
100
|
+
v2 = np.array(p) - np.array(a)
|
|
101
|
+
dot00 = np.dot(v0, v0)
|
|
102
|
+
dot01 = np.dot(v0, v1)
|
|
103
|
+
dot02 = np.dot(v0, v2)
|
|
104
|
+
dot11 = np.dot(v1, v1)
|
|
105
|
+
dot12 = np.dot(v1, v2)
|
|
106
|
+
denom_val = dot00 * dot11 - dot01 * dot01
|
|
107
|
+
invDenom = 1 / denom_val if denom_val != 0 else 1.0
|
|
108
|
+
u = (dot11 * dot02 - dot01 * dot12) * invDenom
|
|
109
|
+
v = (dot00 * dot12 - dot01 * dot02) * invDenom
|
|
110
|
+
return (u >= -1e-9) and (v >= -1e-9) and (u + v <= 1.0 + 1e-9)
|
|
111
|
+
|
|
112
|
+
limit = 100
|
|
113
|
+
while len(poly) > 3 and limit > 0:
|
|
114
|
+
limit -= 1
|
|
115
|
+
ear_found = False
|
|
116
|
+
for i in range(len(poly)):
|
|
117
|
+
prev_idx = poly[i - 1]
|
|
118
|
+
curr_idx = poly[i]
|
|
119
|
+
next_idx = poly[(i + 1) % len(poly)]
|
|
120
|
+
|
|
121
|
+
p_prev = verts_2d[prev_idx]
|
|
122
|
+
p_curr = verts_2d[curr_idx]
|
|
123
|
+
p_next = verts_2d[next_idx]
|
|
124
|
+
|
|
125
|
+
if is_ccw(p_prev, p_curr, p_next):
|
|
126
|
+
any_inside = False
|
|
127
|
+
for idx in poly:
|
|
128
|
+
if idx in (prev_idx, curr_idx, next_idx):
|
|
129
|
+
continue
|
|
130
|
+
if is_inside_triangle(verts_2d[idx], p_prev, p_curr, p_next):
|
|
131
|
+
any_inside = True
|
|
132
|
+
break
|
|
133
|
+
if not any_inside:
|
|
134
|
+
triangles.append([prev_idx, curr_idx, next_idx])
|
|
135
|
+
poly.pop(i)
|
|
136
|
+
ear_found = True
|
|
137
|
+
break
|
|
138
|
+
if not ear_found:
|
|
139
|
+
triangles.append([poly[0], poly[1], poly[2]])
|
|
140
|
+
poly.pop(1)
|
|
141
|
+
|
|
142
|
+
if len(poly) == 3:
|
|
143
|
+
triangles.append(list(poly))
|
|
144
|
+
return triangles
|
|
145
|
+
|
|
146
|
+
def load_off(filepath):
|
|
147
|
+
try:
|
|
148
|
+
with open(filepath, 'r', errors='ignore') as f:
|
|
149
|
+
lines = [line.strip() for line in f if line.strip() and not line.strip().startswith('#')]
|
|
150
|
+
|
|
151
|
+
if not lines:
|
|
152
|
+
print(f"Error: {filepath} is empty or contains only comments.")
|
|
153
|
+
return None, None, None, None, None
|
|
154
|
+
|
|
155
|
+
header = lines[0]
|
|
156
|
+
is_coff = header.startswith('COFF')
|
|
157
|
+
|
|
158
|
+
start_idx = 1
|
|
159
|
+
if header in ['OFF', 'COFF']:
|
|
160
|
+
parts = lines[1].split()
|
|
161
|
+
start_idx = 2
|
|
162
|
+
elif header.startswith('OFF') or header.startswith('COFF'):
|
|
163
|
+
parts = header[4:].split() if is_coff else header[3:].split()
|
|
164
|
+
else:
|
|
165
|
+
print(f"Error: {filepath} has invalid OFF header line: '{header}'")
|
|
166
|
+
return None, None, None, None, None
|
|
167
|
+
|
|
168
|
+
num_verts = int(parts[0])
|
|
169
|
+
num_faces = int(parts[1])
|
|
170
|
+
|
|
171
|
+
vertices = []
|
|
172
|
+
vert_colors = []
|
|
173
|
+
|
|
174
|
+
vertex_lines = lines[start_idx : start_idx + num_verts]
|
|
175
|
+
try:
|
|
176
|
+
vertices_data = np.fromstring("\n".join(vertex_lines), sep=' ')
|
|
177
|
+
num_cols = len(vertices_data) // num_verts
|
|
178
|
+
vertices_data = vertices_data.reshape((num_verts, num_cols))
|
|
179
|
+
vertices = vertices_data[:, :3].tolist()
|
|
180
|
+
if is_coff and num_cols >= 6:
|
|
181
|
+
vert_colors = [parse_color(vertices_data[i, 3:7].tolist()) for i in range(num_verts)]
|
|
182
|
+
else:
|
|
183
|
+
vert_colors = [[0.7, 0.8, 0.9, 1.0]] * num_verts
|
|
184
|
+
except Exception:
|
|
185
|
+
vertices = []
|
|
186
|
+
vert_colors = []
|
|
187
|
+
for i in range(num_verts):
|
|
188
|
+
line_parts = lines[start_idx + i].split()
|
|
189
|
+
vertices.append([float(x) for x in line_parts[:3]])
|
|
190
|
+
if is_coff and len(line_parts) >= 6:
|
|
191
|
+
vert_colors.append(parse_color(line_parts[3:7]))
|
|
192
|
+
else:
|
|
193
|
+
vert_colors.append([0.7, 0.8, 0.9, 1.0])
|
|
194
|
+
|
|
195
|
+
faces = []
|
|
196
|
+
face_colors = []
|
|
197
|
+
triangle_to_face_map = []
|
|
198
|
+
global_inter_map = {}
|
|
199
|
+
|
|
200
|
+
for i in range(num_faces):
|
|
201
|
+
line_parts = lines[start_idx + num_verts + i].split()
|
|
202
|
+
n_v = int(line_parts[0])
|
|
203
|
+
face_verts = [int(x) for x in line_parts[1:1+n_v]]
|
|
204
|
+
|
|
205
|
+
color_parts = line_parts[1+n_v:]
|
|
206
|
+
f_color = parse_color(color_parts, default_color=None) if color_parts else None
|
|
207
|
+
|
|
208
|
+
if n_v < 3:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
pts_3d = [np.array(vertices[idx], dtype='f4') for idx in face_verts]
|
|
212
|
+
|
|
213
|
+
N = np.zeros(3, dtype='f4')
|
|
214
|
+
for k in range(n_v):
|
|
215
|
+
p_curr = pts_3d[k]
|
|
216
|
+
p_next = pts_3d[(k + 1) % n_v]
|
|
217
|
+
N[0] += (p_curr[1] - p_next[1]) * (p_curr[2] + p_next[2])
|
|
218
|
+
N[1] += (p_curr[2] - p_next[2]) * (p_curr[0] + p_next[0])
|
|
219
|
+
N[2] += (p_curr[0] - p_next[0]) * (p_curr[1] + p_next[1])
|
|
220
|
+
|
|
221
|
+
norm_N = np.linalg.norm(N)
|
|
222
|
+
if norm_N < 1e-9:
|
|
223
|
+
for j in range(1, n_v - 1):
|
|
224
|
+
faces.append([face_verts[0], face_verts[j], face_verts[j + 1]])
|
|
225
|
+
face_colors.append(f_color)
|
|
226
|
+
triangle_to_face_map.append(i)
|
|
227
|
+
continue
|
|
228
|
+
N = N / norm_N
|
|
229
|
+
|
|
230
|
+
U = pts_3d[1] - pts_3d[0]
|
|
231
|
+
U = U - np.dot(U, N) * N
|
|
232
|
+
norm_U = np.linalg.norm(U)
|
|
233
|
+
if norm_U < 1e-9:
|
|
234
|
+
U = pts_3d[2] - pts_3d[0]
|
|
235
|
+
U = U - np.dot(U, N) * N
|
|
236
|
+
norm_U = np.linalg.norm(U)
|
|
237
|
+
|
|
238
|
+
if norm_U < 1e-9:
|
|
239
|
+
if abs(N[0]) > 0.9:
|
|
240
|
+
U = np.array([0.0, 1.0, 0.0], dtype='f4')
|
|
241
|
+
else:
|
|
242
|
+
U = np.array([1.0, 0.0, 0.0], dtype='f4')
|
|
243
|
+
U = U - np.dot(U, N) * N
|
|
244
|
+
U = U / np.linalg.norm(U)
|
|
245
|
+
else:
|
|
246
|
+
U = U / norm_U
|
|
247
|
+
|
|
248
|
+
V = np.cross(N, U)
|
|
249
|
+
|
|
250
|
+
pts_2d = []
|
|
251
|
+
for p in pts_3d:
|
|
252
|
+
pts_2d.append([float(np.dot(p - pts_3d[0], U)), float(np.dot(p - pts_3d[0], V))])
|
|
253
|
+
|
|
254
|
+
has_intersection = False
|
|
255
|
+
edge_intersections = {k: [] for k in range(n_v)}
|
|
256
|
+
|
|
257
|
+
local_vertices = []
|
|
258
|
+
for idx in range(n_v):
|
|
259
|
+
local_vertices.append({
|
|
260
|
+
'2d': np.array(pts_2d[idx]),
|
|
261
|
+
'3d': pts_3d[idx].tolist(),
|
|
262
|
+
'color': vert_colors[face_verts[idx]]
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
for k in range(n_v):
|
|
266
|
+
for m in range(k + 2, n_v):
|
|
267
|
+
if k == 0 and m == n_v - 1:
|
|
268
|
+
continue
|
|
269
|
+
A, B = pts_2d[k], pts_2d[(k + 1) % n_v]
|
|
270
|
+
C, D = pts_2d[m], pts_2d[(m + 1) % n_v]
|
|
271
|
+
res = seg_intersect(A, B, C, D)
|
|
272
|
+
if res is not None:
|
|
273
|
+
t, u = res
|
|
274
|
+
has_intersection = True
|
|
275
|
+
|
|
276
|
+
p3d_orig = (1 - t) * pts_3d[k] + t * pts_3d[(k + 1) % n_v]
|
|
277
|
+
p2d_orig = (1 - t) * np.array(A) + t * np.array(B)
|
|
278
|
+
color_A = np.array(vert_colors[face_verts[k]])
|
|
279
|
+
color_B = np.array(vert_colors[face_verts[(k + 1) % n_v]])
|
|
280
|
+
p_color = ((1 - t) * color_A + t * color_B).tolist()
|
|
281
|
+
|
|
282
|
+
v_idx = -1
|
|
283
|
+
for idx, v in enumerate(local_vertices):
|
|
284
|
+
if np.linalg.norm(v['2d'] - p2d_orig) < 1e-5:
|
|
285
|
+
v_idx = idx
|
|
286
|
+
break
|
|
287
|
+
if v_idx == -1:
|
|
288
|
+
local_vertices.append({
|
|
289
|
+
'2d': p2d_orig,
|
|
290
|
+
'3d': p3d_orig.tolist(),
|
|
291
|
+
'color': p_color
|
|
292
|
+
})
|
|
293
|
+
v_idx = len(local_vertices) - 1
|
|
294
|
+
|
|
295
|
+
edge_intersections[k].append((t, v_idx))
|
|
296
|
+
edge_intersections[m].append((u, v_idx))
|
|
297
|
+
|
|
298
|
+
if not has_intersection:
|
|
299
|
+
local_indices = list(range(n_v))
|
|
300
|
+
local_tris = triangulate_simple_polygon(local_indices, pts_2d)
|
|
301
|
+
for tri in local_tris:
|
|
302
|
+
faces.append([face_verts[tri[0]], face_verts[tri[1]], face_verts[tri[2]]])
|
|
303
|
+
face_colors.append(f_color)
|
|
304
|
+
triangle_to_face_map.append(i)
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
# Planar Graph Traversal Solver
|
|
308
|
+
segments = []
|
|
309
|
+
for k in range(n_v):
|
|
310
|
+
v_start = k
|
|
311
|
+
v_end = (k + 1) % n_v
|
|
312
|
+
inters = sorted(edge_intersections[k], key=lambda x: x[0])
|
|
313
|
+
|
|
314
|
+
curr_idx = v_start
|
|
315
|
+
for _, inter_idx in inters:
|
|
316
|
+
segments.append((curr_idx, inter_idx))
|
|
317
|
+
curr_idx = inter_idx
|
|
318
|
+
segments.append((curr_idx, v_end))
|
|
319
|
+
|
|
320
|
+
cleaned_segments = []
|
|
321
|
+
seen_segments = set()
|
|
322
|
+
for u, v in segments:
|
|
323
|
+
if u == v:
|
|
324
|
+
continue
|
|
325
|
+
seg_key = tuple(sorted((u, v)))
|
|
326
|
+
if seg_key not in seen_segments:
|
|
327
|
+
seen_segments.add(seg_key)
|
|
328
|
+
cleaned_segments.append((u, v))
|
|
329
|
+
|
|
330
|
+
adj = {idx: [] for idx in range(len(local_vertices))}
|
|
331
|
+
half_edges = []
|
|
332
|
+
half_edge_lookup = {}
|
|
333
|
+
|
|
334
|
+
for u, v in cleaned_segments:
|
|
335
|
+
half_edges.append({'from': u, 'to': v, 'visited': False})
|
|
336
|
+
half_edge_lookup[(u, v)] = len(half_edges) - 1
|
|
337
|
+
adj[u].append(v)
|
|
338
|
+
|
|
339
|
+
half_edges.append({'from': v, 'to': u, 'visited': False})
|
|
340
|
+
half_edge_lookup[(v, u)] = len(half_edges) - 1
|
|
341
|
+
adj[v].append(u)
|
|
342
|
+
|
|
343
|
+
sorted_adj = {}
|
|
344
|
+
for u in range(len(local_vertices)):
|
|
345
|
+
def get_angle(v):
|
|
346
|
+
diff = local_vertices[v]['2d'] - local_vertices[u]['2d']
|
|
347
|
+
return math.atan2(diff[1], diff[0])
|
|
348
|
+
sorted_adj[u] = sorted(adj[u], key=get_angle)
|
|
349
|
+
|
|
350
|
+
loops = []
|
|
351
|
+
for he_idx, he in enumerate(half_edges):
|
|
352
|
+
if he['visited']:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
cycle = []
|
|
356
|
+
curr_he_idx = he_idx
|
|
357
|
+
|
|
358
|
+
while True:
|
|
359
|
+
curr_he = half_edges[curr_he_idx]
|
|
360
|
+
if curr_he['visited']:
|
|
361
|
+
break
|
|
362
|
+
curr_he['visited'] = True
|
|
363
|
+
u = curr_he['from']
|
|
364
|
+
v = curr_he['to']
|
|
365
|
+
cycle.append(v)
|
|
366
|
+
|
|
367
|
+
v_neighbors = sorted_adj[v]
|
|
368
|
+
u_idx = v_neighbors.index(u)
|
|
369
|
+
next_neighbor = v_neighbors[(u_idx - 1) % len(v_neighbors)]
|
|
370
|
+
curr_he_idx = half_edge_lookup[(v, next_neighbor)]
|
|
371
|
+
|
|
372
|
+
if len(cycle) >= 3:
|
|
373
|
+
loops.append(cycle)
|
|
374
|
+
|
|
375
|
+
interior_loops = []
|
|
376
|
+
for cycle in loops:
|
|
377
|
+
area = 0.0
|
|
378
|
+
n_c = len(cycle)
|
|
379
|
+
for idx_c in range(n_c):
|
|
380
|
+
p1 = local_vertices[cycle[idx_c]]['2d']
|
|
381
|
+
p2 = local_vertices[cycle[(idx_c + 1) % n_c]]['2d']
|
|
382
|
+
area += p1[0] * p2[1] - p2[0] * p1[1]
|
|
383
|
+
area = 0.5 * area
|
|
384
|
+
if area > 1e-7:
|
|
385
|
+
interior_loops.append(cycle)
|
|
386
|
+
|
|
387
|
+
coord_to_global = {}
|
|
388
|
+
for idx in face_verts:
|
|
389
|
+
p = vertices[idx]
|
|
390
|
+
key = (round(p[0], 6), round(p[1], 6), round(p[2], 6))
|
|
391
|
+
coord_to_global[key] = idx
|
|
392
|
+
|
|
393
|
+
def get_global_index(p3d, p_col):
|
|
394
|
+
key = (round(p3d[0], 6), round(p3d[1], 6), round(p3d[2], 6))
|
|
395
|
+
if key in coord_to_global:
|
|
396
|
+
return coord_to_global[key]
|
|
397
|
+
if key in global_inter_map:
|
|
398
|
+
return global_inter_map[key]
|
|
399
|
+
g_idx = len(vertices)
|
|
400
|
+
vertices.append(p3d)
|
|
401
|
+
vert_colors.append(p_col)
|
|
402
|
+
global_inter_map[key] = g_idx
|
|
403
|
+
return g_idx
|
|
404
|
+
|
|
405
|
+
for loop in interior_loops:
|
|
406
|
+
loop_2d = [local_vertices[v_idx]['2d'] for v_idx in loop]
|
|
407
|
+
local_indices = list(range(len(loop_2d)))
|
|
408
|
+
local_tris = triangulate_simple_polygon(local_indices, loop_2d)
|
|
409
|
+
for tri in local_tris:
|
|
410
|
+
g_tri = [
|
|
411
|
+
get_global_index(local_vertices[loop[tri[0]]]['3d'], local_vertices[loop[tri[0]]]['color']),
|
|
412
|
+
get_global_index(local_vertices[loop[tri[1]]]['3d'], local_vertices[loop[tri[1]]]['color']),
|
|
413
|
+
get_global_index(local_vertices[loop[tri[2]]]['3d'], local_vertices[loop[tri[2]]]['color'])
|
|
414
|
+
]
|
|
415
|
+
faces.append(g_tri)
|
|
416
|
+
face_colors.append(f_color)
|
|
417
|
+
triangle_to_face_map.append(i)
|
|
418
|
+
|
|
419
|
+
if len(faces) == 0:
|
|
420
|
+
print(f"Warning: {filepath} has 0 faces after triangulation.")
|
|
421
|
+
|
|
422
|
+
return np.array(vertices, dtype='f4'), np.array(faces, dtype='i4'), vert_colors, face_colors, triangle_to_face_map
|
|
423
|
+
except Exception as e:
|
|
424
|
+
print(f"Error loading {filepath}: {e}")
|
|
425
|
+
import traceback
|
|
426
|
+
traceback.print_exc()
|
|
427
|
+
return None, None, None, None, None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def create_perspective_matrix(fovy, aspect, near, far):
|
|
431
|
+
scale = math.tan(math.radians(fovy) / 2)
|
|
432
|
+
try:
|
|
433
|
+
ty = 1 / scale
|
|
434
|
+
except ZeroDivisionError:
|
|
435
|
+
ty = 1.0
|
|
436
|
+
tx = ty / aspect
|
|
437
|
+
tz = -(far + near) / (far - near)
|
|
438
|
+
tw = -2 * far * near / (far - near)
|
|
439
|
+
return np.array([
|
|
440
|
+
[tx, 0, 0, 0],
|
|
441
|
+
[0, ty, 0, 0],
|
|
442
|
+
[0, 0, tz, -1],
|
|
443
|
+
[0, 0, tw, 0]
|
|
444
|
+
], dtype='f4')
|
|
445
|
+
|
|
446
|
+
def create_lookat_matrix(eye, target, up):
|
|
447
|
+
zaxis = eye - target
|
|
448
|
+
zaxis_norm = np.linalg.norm(zaxis)
|
|
449
|
+
zaxis = zaxis / (zaxis_norm if zaxis_norm != 0 else 1.0)
|
|
450
|
+
xaxis = np.cross(up, zaxis)
|
|
451
|
+
xaxis_norm = np.linalg.norm(xaxis)
|
|
452
|
+
xaxis = xaxis / (xaxis_norm if xaxis_norm != 0 else 1.0)
|
|
453
|
+
yaxis = np.cross(zaxis, xaxis)
|
|
454
|
+
|
|
455
|
+
return np.array([
|
|
456
|
+
[xaxis[0], yaxis[0], zaxis[0], 0],
|
|
457
|
+
[xaxis[1], yaxis[1], zaxis[1], 0],
|
|
458
|
+
[xaxis[2], yaxis[2], zaxis[2], 0],
|
|
459
|
+
[-np.dot(xaxis, eye), -np.dot(yaxis, eye), -np.dot(zaxis, eye), 1]
|
|
460
|
+
], dtype='f4')
|
|
461
|
+
|
|
462
|
+
def create_sphere_mesh(radius=0.02, rings=8, sectors=8):
|
|
463
|
+
verts = []
|
|
464
|
+
faces = []
|
|
465
|
+
for r in range(rings + 1):
|
|
466
|
+
phi = math.pi * r / rings
|
|
467
|
+
for s in range(sectors):
|
|
468
|
+
theta = 2 * math.pi * s / sectors
|
|
469
|
+
x = radius * math.sin(phi) * math.cos(theta)
|
|
470
|
+
y = radius * math.cos(phi)
|
|
471
|
+
z = radius * math.sin(phi) * math.sin(theta)
|
|
472
|
+
verts.append([x, y, z])
|
|
473
|
+
for r in range(rings):
|
|
474
|
+
for s in range(sectors):
|
|
475
|
+
r0 = r * sectors + s
|
|
476
|
+
r1 = r * sectors + (s + 1) % sectors
|
|
477
|
+
r2 = (r + 1) * sectors + s
|
|
478
|
+
r3 = (r + 1) * sectors + (s + 1) % sectors
|
|
479
|
+
faces.append([r0, r2, r1])
|
|
480
|
+
faces.append([r1, r2, r3])
|
|
481
|
+
return np.array(verts, dtype='f4'), np.array(faces, dtype='i4')
|
|
482
|
+
|
|
483
|
+
class ModelViewer:
|
|
484
|
+
def __init__(self, filepath, ctx, program_3d):
|
|
485
|
+
self.filepath = filepath
|
|
486
|
+
self.filename = os.path.basename(filepath)
|
|
487
|
+
self.ctx = ctx
|
|
488
|
+
self.program = program_3d
|
|
489
|
+
self.metrics = {}
|
|
490
|
+
|
|
491
|
+
self.rot_mat = np.eye(3, dtype='f4')
|
|
492
|
+
|
|
493
|
+
verts, faces, vert_colors, face_colors, tri_to_face = load_off(filepath)
|
|
494
|
+
if verts is None or faces is None or len(faces) == 0 or len(verts) == 0:
|
|
495
|
+
self.valid = False
|
|
496
|
+
return
|
|
497
|
+
self.valid = True
|
|
498
|
+
|
|
499
|
+
centroid = np.mean(verts, axis=0)
|
|
500
|
+
verts = verts - centroid
|
|
501
|
+
max_span = np.max(np.linalg.norm(verts, axis=1))
|
|
502
|
+
if max_span > 0:
|
|
503
|
+
verts = verts / max_span
|
|
504
|
+
|
|
505
|
+
tri_verts = verts[faces]
|
|
506
|
+
v0 = tri_verts[:, 0, :]
|
|
507
|
+
v1 = tri_verts[:, 1, :]
|
|
508
|
+
v2 = tri_verts[:, 2, :]
|
|
509
|
+
normals = np.cross(v1 - v0, v2 - v0)
|
|
510
|
+
norms = np.linalg.norm(normals, axis=1, keepdims=True)
|
|
511
|
+
norms[norms == 0] = 1.0
|
|
512
|
+
normals = normals / norms
|
|
513
|
+
|
|
514
|
+
num_triangles = len(faces)
|
|
515
|
+
vbo_array = np.zeros((num_triangles, 3, 10), dtype='f4')
|
|
516
|
+
vbo_array[:, :, 0:3] = verts[faces]
|
|
517
|
+
vbo_array[:, :, 7:10] = normals[:, np.newaxis, :]
|
|
518
|
+
|
|
519
|
+
vert_colors_arr = np.array(vert_colors, dtype='f4')
|
|
520
|
+
default_colors = vert_colors_arr[faces]
|
|
521
|
+
|
|
522
|
+
face_colors_clean = [fc for fc in face_colors if fc is not None]
|
|
523
|
+
if len(face_colors_clean) == 0:
|
|
524
|
+
vbo_array[:, :, 3:7] = default_colors
|
|
525
|
+
else:
|
|
526
|
+
vbo_array[:, :, 3:7] = default_colors
|
|
527
|
+
for i, f_col in enumerate(face_colors):
|
|
528
|
+
if f_col is not None:
|
|
529
|
+
vbo_array[i, :, 3:7] = f_col
|
|
530
|
+
|
|
531
|
+
self.vbo_data = vbo_array.ravel()
|
|
532
|
+
self.vbo = ctx.buffer(self.vbo_data.tobytes())
|
|
533
|
+
self.vao = ctx.vertex_array(self.program, [
|
|
534
|
+
(self.vbo, '3f 4f 3f', 'in_position', 'in_color', 'in_normal')
|
|
535
|
+
])
|
|
536
|
+
|
|
537
|
+
# Track rendering vertex ranges for original polyhedron faces
|
|
538
|
+
self.face_ranges = {}
|
|
539
|
+
current_vertex = 0
|
|
540
|
+
for i in range(len(faces)):
|
|
541
|
+
orig_face_idx = tri_to_face[i]
|
|
542
|
+
if orig_face_idx not in self.face_ranges:
|
|
543
|
+
self.face_ranges[orig_face_idx] = [current_vertex, 0]
|
|
544
|
+
self.face_ranges[orig_face_idx][1] += 3
|
|
545
|
+
current_vertex += 3
|
|
546
|
+
self.num_original_faces = len(self.face_ranges)
|
|
547
|
+
|
|
548
|
+
# Track original faces adjacent to each vertex
|
|
549
|
+
self.vertex_to_original_faces = {v_idx: set() for v_idx in range(len(verts))}
|
|
550
|
+
for i in range(len(faces)):
|
|
551
|
+
orig_face_idx = tri_to_face[i]
|
|
552
|
+
for j in range(3):
|
|
553
|
+
v_idx = faces[i][j]
|
|
554
|
+
self.vertex_to_original_faces[v_idx].add(orig_face_idx)
|
|
555
|
+
self.num_vertices = len(verts)
|
|
556
|
+
|
|
557
|
+
# Track face triangulations to extract boundary edges
|
|
558
|
+
face_triangles = defaultdict(list)
|
|
559
|
+
for i in range(len(faces)):
|
|
560
|
+
orig_face_idx = tri_to_face[i]
|
|
561
|
+
face_triangles[orig_face_idx].append(faces[i])
|
|
562
|
+
|
|
563
|
+
self.face_boundary_edges = {}
|
|
564
|
+
for orig_face_idx, tris in face_triangles.items():
|
|
565
|
+
edge_counts = defaultdict(int)
|
|
566
|
+
for tri in tris:
|
|
567
|
+
for u, v in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]:
|
|
568
|
+
edge_key = tuple(sorted((u, v)))
|
|
569
|
+
edge_counts[edge_key] += 1
|
|
570
|
+
self.face_boundary_edges[orig_face_idx] = [edge for edge, count in edge_counts.items() if count == 1]
|
|
571
|
+
|
|
572
|
+
all_edges_set = set()
|
|
573
|
+
for edges in self.face_boundary_edges.values():
|
|
574
|
+
all_edges_set.update(edges)
|
|
575
|
+
self.all_edges = list(all_edges_set)
|
|
576
|
+
|
|
577
|
+
# Store scaled verts and sphere mesh local data
|
|
578
|
+
self.verts = verts
|
|
579
|
+
self.sphere_local_verts, self.sphere_faces = create_sphere_mesh(radius=0.015, rings=8, sectors=8)
|
|
580
|
+
|
|
581
|
+
def rotate_camera(self, dx, dy):
|
|
582
|
+
if dx != 0:
|
|
583
|
+
c, s = math.cos(dx), math.sin(dx)
|
|
584
|
+
ry = np.array([
|
|
585
|
+
[c, 0, s],
|
|
586
|
+
[0, 1, 0],
|
|
587
|
+
[-s, 0, c]
|
|
588
|
+
], dtype='f4')
|
|
589
|
+
self.rot_mat = self.rot_mat @ ry
|
|
590
|
+
|
|
591
|
+
if dy != 0:
|
|
592
|
+
c, s = math.cos(dy), math.sin(dy)
|
|
593
|
+
rx = np.array([
|
|
594
|
+
[1, 0, 0],
|
|
595
|
+
[0, c, -s],
|
|
596
|
+
[0, s, c]
|
|
597
|
+
], dtype='f4')
|
|
598
|
+
self.rot_mat = self.rot_mat @ rx
|
|
599
|
+
|
|
600
|
+
def render_sphere_at(self, pos):
|
|
601
|
+
translated_data = []
|
|
602
|
+
black_color = [0.0, 0.0, 0.0, 1.0]
|
|
603
|
+
for tri in self.sphere_faces:
|
|
604
|
+
for idx in tri:
|
|
605
|
+
v = self.sphere_local_verts[idx]
|
|
606
|
+
p = v + pos
|
|
607
|
+
n = v / np.linalg.norm(v) if np.linalg.norm(v) > 0 else np.array([0.0, 1.0, 0.0], dtype='f4')
|
|
608
|
+
translated_data.extend([p[0], p[1], p[2]] + black_color + [n[0], n[1], n[2]])
|
|
609
|
+
|
|
610
|
+
temp_vbo = self.ctx.buffer(np.array(translated_data, dtype='f4').tobytes())
|
|
611
|
+
temp_vao = self.ctx.vertex_array(self.program, [
|
|
612
|
+
(temp_vbo, '3f 4f 3f', 'in_position', 'in_color', 'in_normal')
|
|
613
|
+
])
|
|
614
|
+
temp_vao.render(moderngl.TRIANGLES)
|
|
615
|
+
temp_vao.release()
|
|
616
|
+
temp_vbo.release()
|
|
617
|
+
|
|
618
|
+
def render_edges(self, edges_to_draw, verts):
|
|
619
|
+
if not edges_to_draw:
|
|
620
|
+
return
|
|
621
|
+
edge_vbo_data = []
|
|
622
|
+
black_color = [0.0, 0.0, 0.0, 1.0]
|
|
623
|
+
dummy_normal = [0.0, 0.0, 0.0]
|
|
624
|
+
for u, v in edges_to_draw:
|
|
625
|
+
pos_u = verts[u]
|
|
626
|
+
pos_v = verts[v]
|
|
627
|
+
edge_vbo_data.extend([pos_u[0], pos_u[1], pos_u[2]] + black_color + dummy_normal)
|
|
628
|
+
edge_vbo_data.extend([pos_v[0], pos_v[1], pos_v[2]] + black_color + dummy_normal)
|
|
629
|
+
|
|
630
|
+
edge_vbo_bytes = np.array(edge_vbo_data, dtype='f4').tobytes()
|
|
631
|
+
temp_vbo = self.ctx.buffer(edge_vbo_bytes)
|
|
632
|
+
temp_vao = self.ctx.vertex_array(self.program, [
|
|
633
|
+
(temp_vbo, '3f 4f 3f', 'in_position', 'in_color', 'in_normal')
|
|
634
|
+
])
|
|
635
|
+
temp_vao.render(moderngl.LINES)
|
|
636
|
+
temp_vao.release()
|
|
637
|
+
temp_vbo.release()
|
|
638
|
+
|
|
639
|
+
def render_3d(self, viewport, aspect, active_face_index=-1, target_x=0.0, active_vertex_index=-1, edge_mode=0):
|
|
640
|
+
if not self.valid:
|
|
641
|
+
return
|
|
642
|
+
self.ctx.viewport = viewport
|
|
643
|
+
|
|
644
|
+
eye = self.rot_mat @ np.array([0.0, 0.0, 3.0], dtype='f4')
|
|
645
|
+
up = self.rot_mat @ np.array([0.0, 1.0, 0.0], dtype='f4')
|
|
646
|
+
|
|
647
|
+
# Calculate horizontal camera pan offset relative to camera frame
|
|
648
|
+
if target_x != 0.0:
|
|
649
|
+
zaxis = eye - np.array([0.0, 0.0, 0.0], dtype='f4')
|
|
650
|
+
zaxis = zaxis / np.linalg.norm(zaxis)
|
|
651
|
+
right = np.cross(up, zaxis)
|
|
652
|
+
right_norm = np.linalg.norm(right)
|
|
653
|
+
right = right / (right_norm if right_norm != 0 else 1.0)
|
|
654
|
+
target = target_x * right
|
|
655
|
+
else:
|
|
656
|
+
target = np.array([0.0, 0.0, 0.0], dtype='f4')
|
|
657
|
+
|
|
658
|
+
proj = create_perspective_matrix(45.0, aspect, 0.1, 10.0)
|
|
659
|
+
view = create_lookat_matrix(eye, target, up)
|
|
660
|
+
|
|
661
|
+
self.program['u_proj'].write(proj.tobytes())
|
|
662
|
+
self.program['u_view'].write(view.tobytes())
|
|
663
|
+
|
|
664
|
+
if active_face_index != -1 and active_face_index in self.face_ranges:
|
|
665
|
+
start_v, count_v = self.face_ranges[active_face_index]
|
|
666
|
+
self.vao.render(moderngl.TRIANGLES, vertices=count_v, first=start_v)
|
|
667
|
+
elif active_vertex_index != -1 and active_vertex_index in self.vertex_to_original_faces:
|
|
668
|
+
for orig_face_idx in self.vertex_to_original_faces[active_vertex_index]:
|
|
669
|
+
if orig_face_idx in self.face_ranges:
|
|
670
|
+
start_v, count_v = self.face_ranges[orig_face_idx]
|
|
671
|
+
self.vao.render(moderngl.TRIANGLES, vertices=count_v, first=start_v)
|
|
672
|
+
else:
|
|
673
|
+
self.vao.render(moderngl.TRIANGLES)
|
|
674
|
+
|
|
675
|
+
if active_vertex_index != -1:
|
|
676
|
+
pos = self.verts[active_vertex_index]
|
|
677
|
+
self.render_sphere_at(pos)
|
|
678
|
+
|
|
679
|
+
if edge_mode > 0:
|
|
680
|
+
edges_to_draw = []
|
|
681
|
+
if active_vertex_index != -1:
|
|
682
|
+
if edge_mode == 1:
|
|
683
|
+
edges_to_draw = [e for e in self.all_edges if e[0] == active_vertex_index or e[1] == active_vertex_index]
|
|
684
|
+
elif edge_mode == 2:
|
|
685
|
+
edges_to_draw = self.all_edges
|
|
686
|
+
elif active_face_index != -1:
|
|
687
|
+
if edge_mode == 1:
|
|
688
|
+
edges_to_draw = self.face_boundary_edges.get(active_face_index, [])
|
|
689
|
+
elif edge_mode == 2:
|
|
690
|
+
edges_to_draw = self.all_edges
|
|
691
|
+
else:
|
|
692
|
+
edges_to_draw = self.all_edges
|
|
693
|
+
|
|
694
|
+
self.render_edges(edges_to_draw, self.verts)
|
|
695
|
+
|
|
696
|
+
def run_bg_analysis(folder, viewers_list):
|
|
697
|
+
bg_run_id[0] += 1
|
|
698
|
+
my_run_id = bg_run_id[0]
|
|
699
|
+
|
|
700
|
+
def worker():
|
|
701
|
+
results = []
|
|
702
|
+
all_keys = set()
|
|
703
|
+
|
|
704
|
+
for v in viewers_list:
|
|
705
|
+
if bg_run_id[0] != my_run_id:
|
|
706
|
+
return
|
|
707
|
+
fpath = os.path.abspath(v.filepath)
|
|
708
|
+
res, stats = verify_off_logic(fpath, return_stats=True, run_symmetry=False)
|
|
709
|
+
if stats is not None:
|
|
710
|
+
results.append(stats)
|
|
711
|
+
all_keys.update(stats.keys())
|
|
712
|
+
else:
|
|
713
|
+
basic_stats = {"Filename": os.path.basename(fpath), "Detail": res}
|
|
714
|
+
results.append(basic_stats)
|
|
715
|
+
all_keys.update(basic_stats.keys())
|
|
716
|
+
|
|
717
|
+
if bg_run_id[0] != my_run_id:
|
|
718
|
+
return
|
|
719
|
+
|
|
720
|
+
headers_list = list(all_keys)
|
|
721
|
+
if "Filename" in headers_list:
|
|
722
|
+
headers_list.remove("Filename")
|
|
723
|
+
if "Detail" in headers_list:
|
|
724
|
+
headers_list.remove("Detail")
|
|
725
|
+
|
|
726
|
+
def extract_num(key_str):
|
|
727
|
+
m = re.search(r'\d+', key_str)
|
|
728
|
+
return int(m.group()) if m else 999999
|
|
729
|
+
|
|
730
|
+
dynamic_headers = [h for h in headers_list if h.startswith("Faces (") or h.startswith("Valence (")]
|
|
731
|
+
base_headers = [h for h in headers_list if h not in dynamic_headers]
|
|
732
|
+
|
|
733
|
+
base_headers.sort()
|
|
734
|
+
dynamic_headers.sort(key=extract_num)
|
|
735
|
+
|
|
736
|
+
headers = ["Filename"] + base_headers + dynamic_headers + ["Detail"]
|
|
737
|
+
|
|
738
|
+
csv_path = os.path.join(folder, '.offviewer_temp.csv')
|
|
739
|
+
try:
|
|
740
|
+
with open(csv_path, 'w', newline='', encoding='utf-8') as csvfile:
|
|
741
|
+
writer = csv.DictWriter(csvfile, fieldnames=headers)
|
|
742
|
+
writer.writeheader()
|
|
743
|
+
for row in results:
|
|
744
|
+
if bg_run_id[0] != my_run_id:
|
|
745
|
+
return
|
|
746
|
+
row_data = {h: row.get(h, "") for h in headers}
|
|
747
|
+
writer.writerow(row_data)
|
|
748
|
+
|
|
749
|
+
if bg_run_id[0] != my_run_id:
|
|
750
|
+
try:
|
|
751
|
+
os.remove(csv_path)
|
|
752
|
+
except Exception:
|
|
753
|
+
pass
|
|
754
|
+
return
|
|
755
|
+
|
|
756
|
+
bg_csv_path[0] = csv_path
|
|
757
|
+
bg_csv_ready[0] = True
|
|
758
|
+
except Exception as e:
|
|
759
|
+
print(f"Failed to write background CSV: {e}")
|
|
760
|
+
|
|
761
|
+
t = threading.Thread(target=worker, daemon=True)
|
|
762
|
+
t.start()
|
|
763
|
+
|
|
764
|
+
def run_single_analysis(filepath, state):
|
|
765
|
+
state['active_report'] = "Generating offcheck report..."
|
|
766
|
+
state['needs_redraw'] = 2
|
|
767
|
+
|
|
768
|
+
def worker():
|
|
769
|
+
report = verify_off_logic(os.path.abspath(filepath), run_symmetry=False)
|
|
770
|
+
state['active_report'] = report
|
|
771
|
+
state['needs_redraw'] = 2
|
|
772
|
+
|
|
773
|
+
t = threading.Thread(target=worker, daemon=True)
|
|
774
|
+
t.start()
|
|
775
|
+
|
|
776
|
+
def load_viewers_from_folder(folder, ctx, prog_3d):
|
|
777
|
+
viewers_list = []
|
|
778
|
+
try:
|
|
779
|
+
off_files = [f for f in os.listdir(folder) if f.lower().endswith('.off')]
|
|
780
|
+
for f in sorted(off_files):
|
|
781
|
+
v = ModelViewer(os.path.join(folder, f), ctx, prog_3d)
|
|
782
|
+
if v.valid:
|
|
783
|
+
viewers_list.append(v)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
print(f"Error scanning folder {folder}: {e}")
|
|
786
|
+
return viewers_list
|
|
787
|
+
|
|
788
|
+
def get_subfolders(folder):
|
|
789
|
+
subfolders_list = []
|
|
790
|
+
try:
|
|
791
|
+
parent = os.path.dirname(folder)
|
|
792
|
+
if parent and parent != folder:
|
|
793
|
+
subfolders_list.append("..")
|
|
794
|
+
for d in sorted(os.listdir(folder)):
|
|
795
|
+
if os.path.isdir(os.path.join(folder, d)) and not d.startswith('.'):
|
|
796
|
+
subfolders_list.append(d)
|
|
797
|
+
except Exception as e:
|
|
798
|
+
print(f"Error listing subfolders: {e}")
|
|
799
|
+
return subfolders_list
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def sort_viewers_by_metric(metric, viewers):
|
|
803
|
+
if metric == "Default":
|
|
804
|
+
viewers.sort(key=lambda v: v.filename.lower())
|
|
805
|
+
elif metric.upper() in ("V-E-F", "VEF"):
|
|
806
|
+
def get_sort_key(v):
|
|
807
|
+
try:
|
|
808
|
+
v_num = int(float(v.metrics.get("Vertices", v.metrics.get("vertices", 0))))
|
|
809
|
+
e_num = int(float(v.metrics.get("Edges", v.metrics.get("edges", 0))))
|
|
810
|
+
f_num = int(float(v.metrics.get("Faces", v.metrics.get("faces", 0))))
|
|
811
|
+
if v_num > 0 or e_num > 0 or f_num > 0:
|
|
812
|
+
return (0, v_num, e_num, f_num)
|
|
813
|
+
except (ValueError, TypeError):
|
|
814
|
+
pass
|
|
815
|
+
|
|
816
|
+
val = v.metrics.get(metric, "")
|
|
817
|
+
if val == "" or val is None:
|
|
818
|
+
return (2, (0, 0, 0))
|
|
819
|
+
if val in ("N/A", "Error", "nan", "NaN"):
|
|
820
|
+
return (1, val.lower())
|
|
821
|
+
|
|
822
|
+
nums = [int(n) for n in re.findall(r'\d+', str(val))]
|
|
823
|
+
if len(nums) >= 3:
|
|
824
|
+
return (0, nums[0], nums[1], nums[2])
|
|
825
|
+
elif len(nums) > 0:
|
|
826
|
+
nums.extend([0] * (3 - len(nums)))
|
|
827
|
+
return (0, nums[0], nums[1], nums[2])
|
|
828
|
+
|
|
829
|
+
return (1, str(val).lower())
|
|
830
|
+
viewers.sort(key=get_sort_key)
|
|
831
|
+
else:
|
|
832
|
+
def get_sort_key(v):
|
|
833
|
+
val = v.metrics.get(metric, "")
|
|
834
|
+
if val == "" or val is None:
|
|
835
|
+
return (2, "")
|
|
836
|
+
if val in ("N/A", "Error", "nan", "NaN"):
|
|
837
|
+
return (1, val.lower())
|
|
838
|
+
try:
|
|
839
|
+
return (0, float(val))
|
|
840
|
+
except ValueError:
|
|
841
|
+
return (1, str(val).lower())
|
|
842
|
+
viewers.sort(key=get_sort_key)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def init_gl_resources(ctx):
|
|
846
|
+
vs_3d = """
|
|
847
|
+
#version 330
|
|
848
|
+
in vec3 in_position;
|
|
849
|
+
in vec4 in_color;
|
|
850
|
+
in vec3 in_normal;
|
|
851
|
+
out vec4 v_color;
|
|
852
|
+
out vec3 v_normal;
|
|
853
|
+
uniform mat4 u_proj;
|
|
854
|
+
uniform mat4 u_view;
|
|
855
|
+
void main() {
|
|
856
|
+
gl_Position = u_proj * u_view * vec4(in_position, 1.0);
|
|
857
|
+
v_color = in_color;
|
|
858
|
+
v_normal = mat3(u_view) * in_normal;
|
|
859
|
+
}
|
|
860
|
+
"""
|
|
861
|
+
|
|
862
|
+
fs_3d = """
|
|
863
|
+
#version 330
|
|
864
|
+
in vec4 v_color;
|
|
865
|
+
in vec3 v_normal;
|
|
866
|
+
out vec4 fragColor;
|
|
867
|
+
void main() {
|
|
868
|
+
vec3 normal = normalize(v_normal);
|
|
869
|
+
if (!gl_FrontFacing) {
|
|
870
|
+
normal = -normal;
|
|
871
|
+
}
|
|
872
|
+
vec3 light_dir1 = normalize(vec3(0.5, 0.5, 1.0));
|
|
873
|
+
vec3 light_dir2 = normalize(vec3(-0.5, -0.3, 0.8));
|
|
874
|
+
vec3 light_dir3 = normalize(vec3(0.0, 1.0, -0.5));
|
|
875
|
+
|
|
876
|
+
float diff1 = max(dot(normal, light_dir1), 0.0) * 0.50;
|
|
877
|
+
float diff2 = max(dot(normal, light_dir2), 0.0) * 0.25;
|
|
878
|
+
float diff3 = max(dot(normal, light_dir3), 0.0) * 0.15;
|
|
879
|
+
|
|
880
|
+
float ambient = 0.35;
|
|
881
|
+
vec3 final_light = v_color.rgb * (diff1 + diff2 + diff3 + ambient);
|
|
882
|
+
fragColor = vec4(final_light, v_color.a);
|
|
883
|
+
}
|
|
884
|
+
"""
|
|
885
|
+
|
|
886
|
+
vs_2d = """
|
|
887
|
+
#version 330
|
|
888
|
+
in vec2 in_position;
|
|
889
|
+
in vec2 in_texcoord;
|
|
890
|
+
out vec2 v_texcoord;
|
|
891
|
+
void main() {
|
|
892
|
+
gl_Position = vec4(in_position, 0.0, 1.0);
|
|
893
|
+
v_texcoord = in_texcoord;
|
|
894
|
+
}
|
|
895
|
+
"""
|
|
896
|
+
|
|
897
|
+
fs_2d = """
|
|
898
|
+
#version 330
|
|
899
|
+
in vec2 v_texcoord;
|
|
900
|
+
out vec4 fragColor;
|
|
901
|
+
uniform sampler2D u_texture;
|
|
902
|
+
void main() {
|
|
903
|
+
fragColor = texture(u_texture, v_texcoord);
|
|
904
|
+
}
|
|
905
|
+
"""
|
|
906
|
+
|
|
907
|
+
prog_3d = ctx.program(vertex_shader=vs_3d, fragment_shader=fs_3d)
|
|
908
|
+
prog_2d = ctx.program(vertex_shader=vs_2d, fragment_shader=fs_2d)
|
|
909
|
+
|
|
910
|
+
quad_data = np.array([
|
|
911
|
+
-1.0, -1.0, 0.0, 0.0,
|
|
912
|
+
1.0, -1.0, 1.0, 0.0,
|
|
913
|
+
-1.0, 1.0, 0.0, 1.0,
|
|
914
|
+
-1.0, 1.0, 0.0, 1.0,
|
|
915
|
+
1.0, -1.0, 1.0, 0.0,
|
|
916
|
+
1.0, 1.0, 1.0, 1.0,
|
|
917
|
+
], dtype='f4')
|
|
918
|
+
quad_vbo = ctx.buffer(quad_data.tobytes())
|
|
919
|
+
quad_vao = ctx.vertex_array(prog_2d, [(quad_vbo, '2f 2f', 'in_position', 'in_texcoord')])
|
|
920
|
+
|
|
921
|
+
return prog_3d, prog_2d, quad_vao
|
|
922
|
+
|
|
923
|
+
def show_bespoke_text_window(filepath):
|
|
924
|
+
def run():
|
|
925
|
+
root = tk.Tk()
|
|
926
|
+
root.title(f"Source: {os.path.basename(filepath)}")
|
|
927
|
+
root.geometry("650x550")
|
|
928
|
+
|
|
929
|
+
scrollbar_y = tk.Scrollbar(root, orient=tk.VERTICAL)
|
|
930
|
+
scrollbar_y.pack(side=tk.RIGHT, fill=tk.Y)
|
|
931
|
+
scrollbar_x = tk.Scrollbar(root, orient=tk.HORIZONTAL)
|
|
932
|
+
scrollbar_x.pack(side=tk.BOTTOM, fill=tk.X)
|
|
933
|
+
|
|
934
|
+
text_widget = tk.Text(root, wrap=tk.NONE, yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set, font=("Courier New", 10))
|
|
935
|
+
text_widget.pack(fill=tk.BOTH, expand=True)
|
|
936
|
+
|
|
937
|
+
scrollbar_y.config(command=text_widget.yview)
|
|
938
|
+
scrollbar_x.config(command=text_widget.xview)
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
with open(filepath, 'r', errors='ignore') as f:
|
|
942
|
+
content = f.read()
|
|
943
|
+
text_widget.insert(tk.END, content)
|
|
944
|
+
except Exception as e:
|
|
945
|
+
text_widget.insert(tk.END, f"Error opening file:\n{e}")
|
|
946
|
+
|
|
947
|
+
text_widget.config(state=tk.DISABLED)
|
|
948
|
+
root.mainloop()
|
|
949
|
+
|
|
950
|
+
t = threading.Thread(target=run, daemon=True)
|
|
951
|
+
t.start()
|
|
952
|
+
|
|
953
|
+
def handle_events(events, state, viewers, subfolders, ctx, prog_3d):
|
|
954
|
+
import subprocess
|
|
955
|
+
mx, my = pygame.mouse.get_pos()
|
|
956
|
+
double_click_threshold = 300
|
|
957
|
+
sidebar_w = 200
|
|
958
|
+
top_bar_h = 75
|
|
959
|
+
item_w = state.get('item_w', 180)
|
|
960
|
+
text_area_h = 45
|
|
961
|
+
item_h = item_w + text_area_h
|
|
962
|
+
|
|
963
|
+
for event in events:
|
|
964
|
+
if event.type == QUIT:
|
|
965
|
+
state['running'] = False
|
|
966
|
+
elif event.type == VIDEORESIZE:
|
|
967
|
+
pygame.display.set_mode(event.size, pygame.OPENGL | pygame.DOUBLEBUF | pygame.RESIZABLE)
|
|
968
|
+
elif event.type == pygame.DROPFILE:
|
|
969
|
+
dropped_path = event.file
|
|
970
|
+
if os.path.isdir(dropped_path):
|
|
971
|
+
state['current_folder'] = os.path.abspath(dropped_path)
|
|
972
|
+
else:
|
|
973
|
+
state['current_folder'] = os.path.abspath(os.path.dirname(dropped_path))
|
|
974
|
+
viewers[:] = load_viewers_from_folder(state['current_folder'], ctx, prog_3d)
|
|
975
|
+
subfolders[:] = get_subfolders(state['current_folder'])
|
|
976
|
+
state['scroll_y'] = 0.0
|
|
977
|
+
state['hovered_index'] = -1
|
|
978
|
+
state['fullscreen_index'] = -1
|
|
979
|
+
state['active_face_index'] = -1
|
|
980
|
+
state['active_vertex_index'] = -1
|
|
981
|
+
state['active_report'] = None
|
|
982
|
+
|
|
983
|
+
state['sort_dropdown_enabled'] = False
|
|
984
|
+
state['selected_sort_metric'] = "Default"
|
|
985
|
+
state['sort_headers'] = []
|
|
986
|
+
state['dropdown_open'] = False
|
|
987
|
+
state['dropdown_scroll'] = 0
|
|
988
|
+
bg_csv_ready[0] = False
|
|
989
|
+
bg_csv_path[0] = None
|
|
990
|
+
state['context_menu_open'] = False
|
|
991
|
+
run_bg_analysis(state['current_folder'], viewers)
|
|
992
|
+
state['needs_redraw'] = 2
|
|
993
|
+
elif event.type == MOUSEBUTTONDOWN:
|
|
994
|
+
event_handled = False
|
|
995
|
+
win_w, win_h = pygame.display.get_window_size()
|
|
996
|
+
|
|
997
|
+
if event.button == 4:
|
|
998
|
+
if state['dropdown_open'] and pygame.Rect(15, 102, 170, min(350, win_h - 120)).collidepoint(mx, my):
|
|
999
|
+
state['dropdown_scroll'] = max(0, state['dropdown_scroll'] - 20)
|
|
1000
|
+
event_handled = True
|
|
1001
|
+
elif state['fullscreen_index'] == -1 and mx > sidebar_w and my > top_bar_h:
|
|
1002
|
+
state['scroll_y'] = max(0.0, min(state['scroll_y'] - 50, state['max_scroll']))
|
|
1003
|
+
event_handled = True
|
|
1004
|
+
elif event.button == 5:
|
|
1005
|
+
if state['dropdown_open'] and pygame.Rect(15, 102, 170, min(350, win_h - 120)).collidepoint(mx, my):
|
|
1006
|
+
all_opts_len = len(["Default"] + state['sort_headers'])
|
|
1007
|
+
max_dp_scroll = max(0, all_opts_len * 20 - min(350, win_h - 120))
|
|
1008
|
+
state['dropdown_scroll'] = min(max_dp_scroll, state['dropdown_scroll'] + 20)
|
|
1009
|
+
event_handled = True
|
|
1010
|
+
elif state['fullscreen_index'] == -1 and mx > sidebar_w and my > top_bar_h:
|
|
1011
|
+
state['scroll_y'] = max(0.0, min(state['scroll_y'] + 50, state['max_scroll']))
|
|
1012
|
+
event_handled = True
|
|
1013
|
+
elif event.button == 3: # Right Click
|
|
1014
|
+
if state['fullscreen_index'] == -1 and state['hovered_index'] != -1:
|
|
1015
|
+
state['context_menu_open'] = True
|
|
1016
|
+
state['context_menu_index'] = state['hovered_index']
|
|
1017
|
+
state['context_menu_pos'] = (mx, my)
|
|
1018
|
+
event_handled = True
|
|
1019
|
+
elif event.button == 1: # Left Click
|
|
1020
|
+
# Clicked within active context menu
|
|
1021
|
+
if state['context_menu_open']:
|
|
1022
|
+
cx, cy = state['context_menu_pos']
|
|
1023
|
+
menu_rect = pygame.Rect(cx, cy, 120, 80)
|
|
1024
|
+
if menu_rect.collidepoint(mx, my):
|
|
1025
|
+
relative_y = my - cy
|
|
1026
|
+
|
|
1027
|
+
root_temp = tk.Tk()
|
|
1028
|
+
root_temp.withdraw()
|
|
1029
|
+
|
|
1030
|
+
if relative_y < 20: # Rename option
|
|
1031
|
+
old_name = viewers[state['context_menu_index']].filename
|
|
1032
|
+
new_name = simpledialog.askstring("Rename File", "Enter new name:", initialvalue=old_name)
|
|
1033
|
+
if new_name:
|
|
1034
|
+
if not new_name.lower().endswith(".off"):
|
|
1035
|
+
new_name += ".off"
|
|
1036
|
+
old_path = viewers[state['context_menu_index']].filepath
|
|
1037
|
+
new_path = os.path.join(os.path.dirname(old_path), new_name)
|
|
1038
|
+
try:
|
|
1039
|
+
os.rename(old_path, new_path)
|
|
1040
|
+
viewers[state['context_menu_index']].filepath = new_path
|
|
1041
|
+
viewers[state['context_menu_index']].filename = new_name
|
|
1042
|
+
run_bg_analysis(state['current_folder'], viewers)
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
messagebox.showerror("Error", f"Could not rename file: {e}")
|
|
1045
|
+
elif relative_y < 40: # Delete option
|
|
1046
|
+
target_v = viewers[state['context_menu_index']]
|
|
1047
|
+
confirm = messagebox.askyesno("Confirm Delete", f"Are you sure you want to permanently delete {target_v.filename}?")
|
|
1048
|
+
if confirm:
|
|
1049
|
+
try:
|
|
1050
|
+
os.remove(target_v.filepath)
|
|
1051
|
+
viewers.pop(state['context_menu_index'])
|
|
1052
|
+
run_bg_analysis(state['current_folder'], viewers)
|
|
1053
|
+
except Exception as e:
|
|
1054
|
+
messagebox.showerror("Error", f"Could not delete file: {e}")
|
|
1055
|
+
elif relative_y < 60: # Open in Stella option
|
|
1056
|
+
stella_path = os.path.join(os.environ.get("ProgramFiles", "C:\\Program Files"), "Stella4D", "Stella4D.exe")
|
|
1057
|
+
if not os.path.exists(stella_path):
|
|
1058
|
+
stella_path = os.path.join(os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"), "Stella4D", "Stella4D.exe")
|
|
1059
|
+
if os.path.exists(stella_path):
|
|
1060
|
+
try:
|
|
1061
|
+
subprocess.Popen([stella_path, os.path.abspath(viewers[state['context_menu_index']].filepath)])
|
|
1062
|
+
except Exception as e:
|
|
1063
|
+
messagebox.showerror("Error", f"Could not launch Stella4D: {e}")
|
|
1064
|
+
else:
|
|
1065
|
+
messagebox.showerror("Error", f"Stella4D not found at expected Program Files paths.")
|
|
1066
|
+
else: # View Source option
|
|
1067
|
+
target_v = viewers[state['context_menu_index']]
|
|
1068
|
+
show_bespoke_text_window(target_v.filepath)
|
|
1069
|
+
|
|
1070
|
+
root_temp.destroy()
|
|
1071
|
+
state['context_menu_open'] = False
|
|
1072
|
+
event_handled = True
|
|
1073
|
+
else:
|
|
1074
|
+
state['context_menu_open'] = False
|
|
1075
|
+
|
|
1076
|
+
# Check Left/Right enlarged view buttons
|
|
1077
|
+
elif state['fullscreen_index'] != -1 and len(viewers) > 0:
|
|
1078
|
+
prev_btn_rect = pygame.Rect(win_w - 210, win_h - 45, 40, 30)
|
|
1079
|
+
next_btn_rect = pygame.Rect(win_w - 160, win_h - 45, 40, 30)
|
|
1080
|
+
grid_btn_rect = pygame.Rect(win_w - 110, win_h - 45, 90, 30)
|
|
1081
|
+
|
|
1082
|
+
if prev_btn_rect.collidepoint(mx, my):
|
|
1083
|
+
new_idx = (state['fullscreen_index'] - 1) % len(viewers)
|
|
1084
|
+
state['fullscreen_index'] = new_idx
|
|
1085
|
+
state['active_face_index'] = -1
|
|
1086
|
+
state['active_vertex_index'] = -1
|
|
1087
|
+
v = viewers[new_idx]
|
|
1088
|
+
state['target_file'] = v.filename
|
|
1089
|
+
run_single_analysis(v.filepath, state)
|
|
1090
|
+
state['needs_redraw'] = 2
|
|
1091
|
+
event_handled = True
|
|
1092
|
+
elif next_btn_rect.collidepoint(mx, my):
|
|
1093
|
+
new_idx = (state['fullscreen_index'] + 1) % len(viewers)
|
|
1094
|
+
state['fullscreen_index'] = new_idx
|
|
1095
|
+
state['active_face_index'] = -1
|
|
1096
|
+
state['active_vertex_index'] = -1
|
|
1097
|
+
v = viewers[new_idx]
|
|
1098
|
+
state['target_file'] = v.filename
|
|
1099
|
+
run_single_analysis(v.filepath, state)
|
|
1100
|
+
state['needs_redraw'] = 2
|
|
1101
|
+
event_handled = True
|
|
1102
|
+
elif grid_btn_rect.collidepoint(mx, my):
|
|
1103
|
+
state['fullscreen_index'] = -1
|
|
1104
|
+
state['active_face_index'] = -1
|
|
1105
|
+
state['active_vertex_index'] = -1
|
|
1106
|
+
state['active_report'] = None
|
|
1107
|
+
state['needs_redraw'] = 2
|
|
1108
|
+
event_handled = True
|
|
1109
|
+
|
|
1110
|
+
if not event_handled and state['dropdown_open'] and state['sort_dropdown_enabled']:
|
|
1111
|
+
dp_h = min(350, win_h - 120)
|
|
1112
|
+
if pygame.Rect(15, 102, 170, dp_h).collidepoint(mx, my):
|
|
1113
|
+
all_opts = ["Default"] + state['sort_headers']
|
|
1114
|
+
relative_y = my - 102 + state['dropdown_scroll']
|
|
1115
|
+
clicked_idx = relative_y // 20
|
|
1116
|
+
if 0 <= clicked_idx < len(all_opts):
|
|
1117
|
+
state['selected_sort_metric'] = all_opts[clicked_idx]
|
|
1118
|
+
sort_viewers_by_metric(state['selected_sort_metric'], viewers)
|
|
1119
|
+
state['dropdown_open'] = False
|
|
1120
|
+
event_handled = True
|
|
1121
|
+
else:
|
|
1122
|
+
state['dropdown_open'] = False
|
|
1123
|
+
|
|
1124
|
+
if not event_handled and pygame.Rect(15, 32, 170, 18).collidepoint(mx, my):
|
|
1125
|
+
state['auto_rotate'] = not state['auto_rotate']
|
|
1126
|
+
event_handled = True
|
|
1127
|
+
|
|
1128
|
+
if not event_handled and state['sort_dropdown_enabled'] and pygame.Rect(15, 75, 170, 22).collidepoint(mx, my):
|
|
1129
|
+
state['dropdown_open'] = not state['dropdown_open']
|
|
1130
|
+
event_handled = True
|
|
1131
|
+
|
|
1132
|
+
# Check 'Delete Duplicates' Checkbox Click
|
|
1133
|
+
if not event_handled and pygame.Rect(15, 103, 170, 16).collidepoint(mx, my):
|
|
1134
|
+
selected_metric = state.get('selected_sort_metric', 'Default')
|
|
1135
|
+
if selected_metric != "Default" and len(viewers) > 1:
|
|
1136
|
+
groups = []
|
|
1137
|
+
current_group = [viewers[0]]
|
|
1138
|
+
|
|
1139
|
+
for v in viewers[1:]:
|
|
1140
|
+
prev_v = current_group[-1]
|
|
1141
|
+
val1 = v.metrics.get(selected_metric, "")
|
|
1142
|
+
val2 = prev_v.metrics.get(selected_metric, "")
|
|
1143
|
+
|
|
1144
|
+
is_equal = False
|
|
1145
|
+
if val1 is not None and val2 is not None and val1 != "" and val2 != "":
|
|
1146
|
+
if val1 == val2:
|
|
1147
|
+
is_equal = True
|
|
1148
|
+
else:
|
|
1149
|
+
try:
|
|
1150
|
+
f1 = float(val1)
|
|
1151
|
+
f2 = float(val2)
|
|
1152
|
+
if abs(f1 - f2) < SORT_TOLERANCE * abs(f2):
|
|
1153
|
+
is_equal = True
|
|
1154
|
+
except (ValueError, TypeError):
|
|
1155
|
+
pass
|
|
1156
|
+
|
|
1157
|
+
if is_equal:
|
|
1158
|
+
current_group.append(v)
|
|
1159
|
+
else:
|
|
1160
|
+
groups.append(current_group)
|
|
1161
|
+
current_group = [v]
|
|
1162
|
+
groups.append(current_group)
|
|
1163
|
+
|
|
1164
|
+
to_delete = []
|
|
1165
|
+
for group in groups:
|
|
1166
|
+
if len(group) > 1:
|
|
1167
|
+
mtimes = []
|
|
1168
|
+
for v in group:
|
|
1169
|
+
try:
|
|
1170
|
+
mtimes.append((os.path.getmtime(v.filepath), v))
|
|
1171
|
+
except Exception:
|
|
1172
|
+
mtimes.append((float('inf'), v))
|
|
1173
|
+
mtimes.sort(key=lambda x: x[0])
|
|
1174
|
+
oldest_v = mtimes[0][1]
|
|
1175
|
+
|
|
1176
|
+
for m, v in mtimes[1:]:
|
|
1177
|
+
to_delete.append(v)
|
|
1178
|
+
|
|
1179
|
+
if to_delete:
|
|
1180
|
+
deleted_count = 0
|
|
1181
|
+
for v in to_delete:
|
|
1182
|
+
try:
|
|
1183
|
+
if os.path.exists(v.filepath):
|
|
1184
|
+
os.remove(v.filepath)
|
|
1185
|
+
if v in viewers:
|
|
1186
|
+
viewers.remove(v)
|
|
1187
|
+
deleted_count += 1
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
print(f"Error deleting duplicate {v.filename}: {e}")
|
|
1190
|
+
|
|
1191
|
+
run_bg_analysis(state['current_folder'], viewers)
|
|
1192
|
+
state['needs_redraw'] = 2
|
|
1193
|
+
|
|
1194
|
+
root_temp = tk.Tk()
|
|
1195
|
+
root_temp.withdraw()
|
|
1196
|
+
messagebox.showinfo("Delete Duplicates", f"Deleted {deleted_count} duplicate files. Kept the oldest from each set.")
|
|
1197
|
+
root_temp.destroy()
|
|
1198
|
+
event_handled = True
|
|
1199
|
+
|
|
1200
|
+
if not event_handled:
|
|
1201
|
+
on_scrollbar_track = (mx >= win_w - 12)
|
|
1202
|
+
grid_h = win_h - top_bar_h
|
|
1203
|
+
if on_scrollbar_track and state['fullscreen_index'] == -1 and state['max_scroll'] > 0:
|
|
1204
|
+
bar_h = max(30, int((grid_h / (state['max_scroll'] + grid_h)) * grid_h))
|
|
1205
|
+
bar_y = top_bar_h + int((state['scroll_y'] / state['max_scroll']) * (grid_h - bar_h))
|
|
1206
|
+
if bar_y <= my <= bar_y + bar_h:
|
|
1207
|
+
state['scrollbar_dragging'] = True
|
|
1208
|
+
state['scrollbar_click_offset'] = my - bar_y
|
|
1209
|
+
else:
|
|
1210
|
+
state['scrollbar_dragging'] = True
|
|
1211
|
+
state['scrollbar_click_offset'] = bar_h // 2
|
|
1212
|
+
track_usable_h = grid_h - bar_h
|
|
1213
|
+
if track_usable_h > 0:
|
|
1214
|
+
click_y_in_track = my - top_bar_h - state['scrollbar_click_offset']
|
|
1215
|
+
ratio = max(0.0, min(1.0, click_y_in_track / track_usable_h))
|
|
1216
|
+
state['scroll_y'] = ratio * state['max_scroll']
|
|
1217
|
+
elif mx <= sidebar_w and my > 155:
|
|
1218
|
+
folder_y = 160
|
|
1219
|
+
clicked_idx = (my - folder_y) // 25
|
|
1220
|
+
if 0 <= clicked_idx < len(subfolders):
|
|
1221
|
+
target = subfolders[clicked_idx]
|
|
1222
|
+
if target == "..":
|
|
1223
|
+
state['current_folder'] = os.path.abspath(os.path.dirname(state['current_folder']))
|
|
1224
|
+
else:
|
|
1225
|
+
state['current_folder'] = os.path.abspath(os.path.join(state['current_folder'], target))
|
|
1226
|
+
viewers[:] = load_viewers_from_folder(state['current_folder'], ctx, prog_3d)
|
|
1227
|
+
subfolders[:] = get_subfolders(state['current_folder'])
|
|
1228
|
+
state['scroll_y'] = 0.0
|
|
1229
|
+
state['hovered_index'] = -1
|
|
1230
|
+
state['fullscreen_index'] = -1
|
|
1231
|
+
state['active_face_index'] = -1
|
|
1232
|
+
state['active_vertex_index'] = -1
|
|
1233
|
+
state['active_report'] = None
|
|
1234
|
+
|
|
1235
|
+
state['sort_dropdown_enabled'] = False
|
|
1236
|
+
state['selected_sort_metric'] = "Default"
|
|
1237
|
+
state['sort_headers'] = []
|
|
1238
|
+
state['dropdown_open'] = False
|
|
1239
|
+
state['dropdown_scroll'] = 0
|
|
1240
|
+
bg_csv_ready[0] = False
|
|
1241
|
+
bg_csv_path[0] = None
|
|
1242
|
+
state['context_menu_open'] = False
|
|
1243
|
+
run_bg_analysis(state['current_folder'], viewers)
|
|
1244
|
+
else:
|
|
1245
|
+
current_time = pygame.time.get_ticks()
|
|
1246
|
+
time_diff = current_time - state['last_click_time']
|
|
1247
|
+
state['last_click_time'] = current_time
|
|
1248
|
+
|
|
1249
|
+
if time_diff < double_click_threshold:
|
|
1250
|
+
if state['fullscreen_index'] != -1:
|
|
1251
|
+
state['fullscreen_index'] = -1
|
|
1252
|
+
state['active_face_index'] = -1
|
|
1253
|
+
state['active_vertex_index'] = -1
|
|
1254
|
+
state['active_report'] = None
|
|
1255
|
+
elif state['hovered_index'] != -1:
|
|
1256
|
+
active_viewer = viewers[state['hovered_index']]
|
|
1257
|
+
state['fullscreen_index'] = state['hovered_index']
|
|
1258
|
+
run_single_analysis(active_viewer.filepath, state)
|
|
1259
|
+
else:
|
|
1260
|
+
if state['fullscreen_index'] != -1:
|
|
1261
|
+
if mx > sidebar_w and my > top_bar_h:
|
|
1262
|
+
state['dragging'] = True
|
|
1263
|
+
state['dragged_index'] = state['fullscreen_index']
|
|
1264
|
+
pygame.mouse.get_rel()
|
|
1265
|
+
else:
|
|
1266
|
+
if mx > sidebar_w and my > top_bar_h and state['hovered_index'] != -1:
|
|
1267
|
+
state['dragging'] = True
|
|
1268
|
+
state['dragged_index'] = state['hovered_index']
|
|
1269
|
+
pygame.mouse.get_rel()
|
|
1270
|
+
elif event.type == MOUSEBUTTONUP:
|
|
1271
|
+
if event.button == 1:
|
|
1272
|
+
state['dragging'] = False
|
|
1273
|
+
state['dragged_index'] = -1
|
|
1274
|
+
state['scrollbar_dragging'] = False
|
|
1275
|
+
elif event.type == MOUSEMOTION:
|
|
1276
|
+
win_w, win_h = pygame.display.get_window_size()
|
|
1277
|
+
grid_h = win_h - top_bar_h
|
|
1278
|
+
if state['scrollbar_dragging'] and state['max_scroll'] > 0:
|
|
1279
|
+
bar_h = max(30, int((grid_h / (state['max_scroll'] + grid_h)) * grid_h))
|
|
1280
|
+
track_usable_h = grid_h - bar_h
|
|
1281
|
+
if track_usable_h > 0:
|
|
1282
|
+
click_y_in_track = my - top_bar_h - state['scrollbar_click_offset']
|
|
1283
|
+
ratio = max(0.0, min(1.0, click_y_in_track / track_usable_h))
|
|
1284
|
+
state['scroll_y'] = ratio * state['max_scroll']
|
|
1285
|
+
elif state['dragging'] and state['dragged_index'] != -1 and 0 <= state['dragged_index'] < len(viewers):
|
|
1286
|
+
dx_pixels, dy_pixels = event.rel
|
|
1287
|
+
rot_scale = 0.005
|
|
1288
|
+
viewers[state['dragged_index']].rotate_camera(-dx_pixels * rot_scale, -dy_pixels * rot_scale)
|
|
1289
|
+
elif event.type == KEYDOWN:
|
|
1290
|
+
if event.key == K_ESCAPE:
|
|
1291
|
+
if state['fullscreen_index'] != -1:
|
|
1292
|
+
state['fullscreen_index'] = -1
|
|
1293
|
+
state['active_face_index'] = -1
|
|
1294
|
+
state['active_vertex_index'] = -1
|
|
1295
|
+
state['active_report'] = None
|
|
1296
|
+
state['needs_redraw'] = 2
|
|
1297
|
+
elif event.key == K_f:
|
|
1298
|
+
if state['fullscreen_index'] != -1 and 0 <= state['fullscreen_index'] < len(viewers):
|
|
1299
|
+
v = viewers[state['fullscreen_index']]
|
|
1300
|
+
num_faces = v.num_original_faces
|
|
1301
|
+
if num_faces > 0:
|
|
1302
|
+
state['active_vertex_index'] = -1
|
|
1303
|
+
state['active_face_index'] = (state['active_face_index'] + 2) % (num_faces + 1) - 1
|
|
1304
|
+
state['needs_redraw'] = 2
|
|
1305
|
+
elif event.key == K_v:
|
|
1306
|
+
if state['fullscreen_index'] != -1 and 0 <= state['fullscreen_index'] < len(viewers):
|
|
1307
|
+
v = viewers[state['fullscreen_index']]
|
|
1308
|
+
num_verts = v.num_vertices
|
|
1309
|
+
if num_verts > 0:
|
|
1310
|
+
state['active_face_index'] = -1
|
|
1311
|
+
state['active_vertex_index'] = (state['active_vertex_index'] + 2) % (num_verts + 1) - 1
|
|
1312
|
+
state['needs_redraw'] = 2
|
|
1313
|
+
elif event.key == K_e:
|
|
1314
|
+
if state['fullscreen_index'] != -1 and 0 <= state['fullscreen_index'] < len(viewers):
|
|
1315
|
+
if state['active_vertex_index'] != -1 or state['active_face_index'] != -1:
|
|
1316
|
+
state['edge_mode'] = (state['edge_mode'] + 1) % 3
|
|
1317
|
+
else:
|
|
1318
|
+
state['edge_mode'] = 2 if state['edge_mode'] == 0 else 0
|
|
1319
|
+
state['needs_redraw'] = 2
|
|
1320
|
+
elif event.key == K_x:
|
|
1321
|
+
if state['fullscreen_index'] != -1:
|
|
1322
|
+
state['active_face_index'] = -1
|
|
1323
|
+
state['active_vertex_index'] = -1
|
|
1324
|
+
state['needs_redraw'] = 2
|
|
1325
|
+
elif event.key in (K_PLUS, K_EQUALS, K_KP_PLUS):
|
|
1326
|
+
state['item_w'] = min(400, state.get('item_w', 180) + 20)
|
|
1327
|
+
state['needs_redraw'] = 2
|
|
1328
|
+
elif event.key in (K_MINUS, K_KP_MINUS):
|
|
1329
|
+
state['item_w'] = max(80, state.get('item_w', 180) - 20)
|
|
1330
|
+
state['needs_redraw'] = 2
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def draw_ui_surface(win_w, win_h, state, viewers, subfolders, font, font_bold, mx, my, off_files_count):
|
|
1334
|
+
sidebar_w = 200
|
|
1335
|
+
top_bar_h = 75
|
|
1336
|
+
padding = 25
|
|
1337
|
+
text_area_h = 45
|
|
1338
|
+
item_w = state.get('item_w', 180)
|
|
1339
|
+
item_h = item_w + text_area_h
|
|
1340
|
+
grid_w = win_w - sidebar_w
|
|
1341
|
+
grid_h = win_h - top_bar_h
|
|
1342
|
+
cols = max(1, (grid_w - padding) // (item_w + padding))
|
|
1343
|
+
|
|
1344
|
+
ui_surf = pygame.Surface((win_w, win_h), pygame.SRCALPHA)
|
|
1345
|
+
ui_surf.fill((0, 0, 0, 0))
|
|
1346
|
+
|
|
1347
|
+
pygame.draw.rect(ui_surf, (255, 255, 255), (sidebar_w, top_bar_h, grid_w, grid_h))
|
|
1348
|
+
|
|
1349
|
+
if state['fullscreen_index'] != -1:
|
|
1350
|
+
if 0 <= state['fullscreen_index'] < len(viewers):
|
|
1351
|
+
v = viewers[state['fullscreen_index']]
|
|
1352
|
+
|
|
1353
|
+
# Draw standard background panel and border
|
|
1354
|
+
pygame.draw.rect(ui_surf, (255, 255, 255), (sidebar_w + 10, top_bar_h + 10, grid_w - 20, win_h - top_bar_h - 20))
|
|
1355
|
+
pygame.draw.rect(ui_surf, (150, 150, 150), (sidebar_w + 10, top_bar_h + 10, grid_w - 20, win_h - top_bar_h - 20), 1)
|
|
1356
|
+
|
|
1357
|
+
# Clear transparent hole across the dynamic 3D display region so model can shine through
|
|
1358
|
+
view_h = win_h - top_bar_h - 60
|
|
1359
|
+
report_active = bool(state.get('active_report'))
|
|
1360
|
+
viewport_w_hole = grid_w - 440 - 30 if report_active else grid_w - 22
|
|
1361
|
+
|
|
1362
|
+
ui_surf.fill((0, 0, 0, 0), (sidebar_w + 11, top_bar_h + 15, viewport_w_hole, view_h))
|
|
1363
|
+
|
|
1364
|
+
# Draw solid report overlay box on the right of the cleared area if active
|
|
1365
|
+
report_text = state.get('active_report')
|
|
1366
|
+
if report_text:
|
|
1367
|
+
report_w = 440
|
|
1368
|
+
report_x = win_w - report_w - 20
|
|
1369
|
+
report_y = top_bar_h + 20
|
|
1370
|
+
report_h = win_h - top_bar_h - 80
|
|
1371
|
+
|
|
1372
|
+
pygame.draw.rect(ui_surf, (248, 249, 250), (report_x, report_y, report_w, report_h))
|
|
1373
|
+
pygame.draw.rect(ui_surf, (220, 224, 230), (report_x, report_y, report_w, report_h), 1)
|
|
1374
|
+
|
|
1375
|
+
font_mono = pygame.font.SysFont('consolas', 12)
|
|
1376
|
+
lines = report_text.split('\n')
|
|
1377
|
+
ty = report_y + 10
|
|
1378
|
+
for line in lines:
|
|
1379
|
+
if ty + 16 > report_y + report_h - 10:
|
|
1380
|
+
break
|
|
1381
|
+
txt_surface = font_mono.render(line, True, (40, 40, 40))
|
|
1382
|
+
ui_surf.blit(txt_surface, (report_x + 15, ty))
|
|
1383
|
+
ty += 16
|
|
1384
|
+
|
|
1385
|
+
selected_metric = state.get('selected_sort_metric', 'Default')
|
|
1386
|
+
label_x = sidebar_w + 30
|
|
1387
|
+
if selected_metric != "Default":
|
|
1388
|
+
lbl = font_bold.render(v.filename, True, (40, 40, 40))
|
|
1389
|
+
ui_surf.blit(lbl, (label_x, win_h - 45))
|
|
1390
|
+
m_val = format_sort_value(v.metrics.get(selected_metric, ""))
|
|
1391
|
+
m_lbl = font.render(f"{selected_metric}: {m_val}", True, (100, 100, 100))
|
|
1392
|
+
ui_surf.blit(m_lbl, (label_x, win_h - 25))
|
|
1393
|
+
else:
|
|
1394
|
+
lbl = font_bold.render(v.filename, True, (40, 40, 40))
|
|
1395
|
+
ui_surf.blit(lbl, (label_x, win_h - 38))
|
|
1396
|
+
|
|
1397
|
+
active_face = state.get('active_face_index', -1)
|
|
1398
|
+
if active_face != -1:
|
|
1399
|
+
face_lbl = font_bold.render(f"Displaying Original Face {active_face + 1} / {v.num_original_faces}", True, (255, 50, 50))
|
|
1400
|
+
ui_surf.blit(face_lbl, (label_x, top_bar_h + 30))
|
|
1401
|
+
|
|
1402
|
+
active_vertex = state.get('active_vertex_index', -1)
|
|
1403
|
+
if active_vertex != -1:
|
|
1404
|
+
vertex_lbl = font_bold.render(f"Displaying Faces Adjacent to Vertex {active_vertex + 1} / {v.num_vertices}", True, (255, 50, 50))
|
|
1405
|
+
ui_surf.blit(vertex_lbl, (label_x, top_bar_h + 30))
|
|
1406
|
+
|
|
1407
|
+
prev_btn_rect = pygame.Rect(win_w - 210, win_h - 45, 40, 30)
|
|
1408
|
+
next_btn_rect = pygame.Rect(win_w - 160, win_h - 45, 40, 30)
|
|
1409
|
+
grid_btn_rect = pygame.Rect(win_w - 110, win_h - 45, 90, 30)
|
|
1410
|
+
|
|
1411
|
+
if prev_btn_rect.collidepoint(mx, my):
|
|
1412
|
+
pygame.draw.rect(ui_surf, (220, 230, 245), prev_btn_rect)
|
|
1413
|
+
pygame.draw.rect(ui_surf, (100, 150, 220), prev_btn_rect, 1)
|
|
1414
|
+
else:
|
|
1415
|
+
pygame.draw.rect(ui_surf, (240, 240, 240), prev_btn_rect)
|
|
1416
|
+
pygame.draw.rect(ui_surf, (180, 180, 180), prev_btn_rect, 1)
|
|
1417
|
+
prev_lbl = font_bold.render("<", True, (50, 50, 50))
|
|
1418
|
+
ui_surf.blit(prev_lbl, prev_lbl.get_rect(center=prev_btn_rect.center))
|
|
1419
|
+
|
|
1420
|
+
if next_btn_rect.collidepoint(mx, my):
|
|
1421
|
+
pygame.draw.rect(ui_surf, (220, 230, 245), next_btn_rect)
|
|
1422
|
+
pygame.draw.rect(ui_surf, (100, 150, 220), next_btn_rect, 1)
|
|
1423
|
+
else:
|
|
1424
|
+
pygame.draw.rect(ui_surf, (240, 240, 240), next_btn_rect)
|
|
1425
|
+
pygame.draw.rect(ui_surf, (180, 180, 180), next_btn_rect, 1)
|
|
1426
|
+
next_lbl = font_bold.render(">", True, (50, 50, 50))
|
|
1427
|
+
ui_surf.blit(next_lbl, next_lbl.get_rect(center=next_btn_rect.center))
|
|
1428
|
+
|
|
1429
|
+
if grid_btn_rect.collidepoint(mx, my):
|
|
1430
|
+
pygame.draw.rect(ui_surf, (220, 230, 245), grid_btn_rect)
|
|
1431
|
+
pygame.draw.rect(ui_surf, (100, 150, 220), grid_btn_rect, 1)
|
|
1432
|
+
else:
|
|
1433
|
+
pygame.draw.rect(ui_surf, (240, 240, 240), grid_btn_rect)
|
|
1434
|
+
pygame.draw.rect(ui_surf, (180, 180, 180), grid_btn_rect, 1)
|
|
1435
|
+
grid_lbl = font_bold.render("Grid View", True, (50, 50, 50))
|
|
1436
|
+
ui_surf.blit(grid_lbl, grid_lbl.get_rect(center=grid_btn_rect.center))
|
|
1437
|
+
else:
|
|
1438
|
+
prev_val = None
|
|
1439
|
+
selected_metric = state.get('selected_sort_metric', 'Default')
|
|
1440
|
+
|
|
1441
|
+
for i, v in enumerate(viewers):
|
|
1442
|
+
row = i // cols
|
|
1443
|
+
col = i % cols
|
|
1444
|
+
|
|
1445
|
+
x = sidebar_w + padding + col * (item_w + padding)
|
|
1446
|
+
y = top_bar_h + padding + row * (item_h + padding) - int(state['scroll_y'])
|
|
1447
|
+
|
|
1448
|
+
current_val = v.metrics.get(selected_metric, None) if selected_metric != "Default" else None
|
|
1449
|
+
|
|
1450
|
+
if y + item_h < top_bar_h or y > win_h:
|
|
1451
|
+
prev_val = current_val
|
|
1452
|
+
continue
|
|
1453
|
+
|
|
1454
|
+
is_hov = (i == state['hovered_index'])
|
|
1455
|
+
bg_col = (233, 244, 253, 255) if is_hov else (255, 255, 255, 255)
|
|
1456
|
+
border_col = (153, 201, 239) if is_hov else (229, 229, 229)
|
|
1457
|
+
|
|
1458
|
+
pygame.draw.rect(ui_surf, bg_col, (x, y, item_w, item_h))
|
|
1459
|
+
pygame.draw.rect(ui_surf, border_col, (x, y, item_w, item_h), 1)
|
|
1460
|
+
|
|
1461
|
+
role_y = max(top_bar_h, y + 1)
|
|
1462
|
+
hole_h = min(win_h, y + item_w - 1) - role_y
|
|
1463
|
+
if hole_h > 0:
|
|
1464
|
+
ui_surf.fill((0, 0, 0, 0), (x + 1, role_y, item_w - 2, hole_h))
|
|
1465
|
+
|
|
1466
|
+
pygame.draw.rect(ui_surf, border_col, (x, y, item_w, item_w), 1)
|
|
1467
|
+
|
|
1468
|
+
is_equal = False
|
|
1469
|
+
if selected_metric != "Default" and current_val is not None and prev_val is not None and current_val != "" and prev_val != "":
|
|
1470
|
+
if current_val == prev_val:
|
|
1471
|
+
is_equal = True
|
|
1472
|
+
else:
|
|
1473
|
+
try:
|
|
1474
|
+
f1 = float(current_val)
|
|
1475
|
+
f2 = float(prev_val)
|
|
1476
|
+
if abs(f1 - f2) < SORT_TOLERANCE * abs(f2):
|
|
1477
|
+
is_equal = True
|
|
1478
|
+
except (ValueError, TypeError):
|
|
1479
|
+
pass
|
|
1480
|
+
|
|
1481
|
+
if selected_metric != "Default" and is_equal:
|
|
1482
|
+
shading_rect = pygame.Rect(x + 5, y + item_w + 5, item_w - 10, text_area_h - 10)
|
|
1483
|
+
pygame.draw.rect(ui_surf, (255, 215, 225), shading_rect, border_radius=4)
|
|
1484
|
+
|
|
1485
|
+
if selected_metric != "Default":
|
|
1486
|
+
lbl = font.render(v.filename, True, (40, 40, 40))
|
|
1487
|
+
lbl_rect = lbl.get_rect(center=(x + item_w // 2, y + item_w + 15))
|
|
1488
|
+
if top_bar_h < lbl_rect.centery < win_h:
|
|
1489
|
+
ui_surf.blit(lbl, lbl_rect)
|
|
1490
|
+
m_val = format_sort_value(v.metrics.get(selected_metric, ""))
|
|
1491
|
+
m_lbl = font.render(m_val, True, (100, 100, 100))
|
|
1492
|
+
m_rect = m_lbl.get_rect(center=(x + item_w // 2, y + item_w + 32))
|
|
1493
|
+
if top_bar_h < m_rect.centery < win_h:
|
|
1494
|
+
ui_surf.blit(m_lbl, m_rect)
|
|
1495
|
+
else:
|
|
1496
|
+
lbl = font.render(v.filename, True, (40, 40, 40))
|
|
1497
|
+
lbl_rect = lbl.get_rect(center=(x + item_w // 2, y + item_w + text_area_h // 2))
|
|
1498
|
+
if top_bar_h < lbl_rect.centery < win_h:
|
|
1499
|
+
ui_surf.blit(lbl, lbl_rect)
|
|
1500
|
+
|
|
1501
|
+
prev_val = current_val
|
|
1502
|
+
|
|
1503
|
+
pygame.draw.rect(ui_surf, (252, 252, 252), (0, 0, sidebar_w, win_h))
|
|
1504
|
+
pygame.draw.rect(ui_surf, (243, 243, 243), (sidebar_w, 0, grid_w, top_bar_h))
|
|
1505
|
+
pygame.draw.line(ui_surf, (220, 220, 220), (sidebar_w, 0), (sidebar_w, win_h), 1)
|
|
1506
|
+
pygame.draw.line(ui_surf, (220, 220, 220), (sidebar_w, top_bar_h), (win_w, top_bar_h), 1)
|
|
1507
|
+
|
|
1508
|
+
suffix = "file" if off_files_count == 1 else "files"
|
|
1509
|
+
txt_ctrl = font_bold.render(f"{off_files_count} OFF {suffix}", True, (0, 0, 0))
|
|
1510
|
+
ui_surf.blit(txt_ctrl, (15, 15))
|
|
1511
|
+
|
|
1512
|
+
pygame.draw.rect(ui_surf, (230, 230, 230), (15, 35, 12, 12))
|
|
1513
|
+
pygame.draw.rect(ui_surf, (150, 150, 150), (15, 35, 12, 12), 1)
|
|
1514
|
+
if state['auto_rotate']:
|
|
1515
|
+
pygame.draw.rect(ui_surf, (0, 102, 204), (18, 38, 6, 6))
|
|
1516
|
+
txt_rotate = font.render("Auto-rotate Models", True, (50, 50, 50))
|
|
1517
|
+
ui_surf.blit(txt_rotate, (35, 33))
|
|
1518
|
+
|
|
1519
|
+
txt_sort_lbl = font.render("Sort models by:", True, (120, 120, 120))
|
|
1520
|
+
ui_surf.blit(txt_sort_lbl, (15, 58))
|
|
1521
|
+
|
|
1522
|
+
bg_drop = (255, 255, 255) if state['sort_dropdown_enabled'] else (240, 240, 240)
|
|
1523
|
+
border_drop = (180, 180, 180) if state['sort_dropdown_enabled'] else (220, 220, 220)
|
|
1524
|
+
text_drop_col = (50, 50, 50) if state['sort_dropdown_enabled'] else (160, 160, 160)
|
|
1525
|
+
|
|
1526
|
+
pygame.draw.rect(ui_surf, bg_drop, (15, 75, 170, 22))
|
|
1527
|
+
pygame.draw.rect(ui_surf, border_drop, (15, 75, 170, 22), 1)
|
|
1528
|
+
|
|
1529
|
+
curr_val_txt = font.render(state['selected_sort_metric'], True, text_drop_col)
|
|
1530
|
+
ui_surf.blit(curr_val_txt, (22, 78))
|
|
1531
|
+
|
|
1532
|
+
arrow_col = (100, 100, 100) if state['sort_dropdown_enabled'] else (180, 180, 180)
|
|
1533
|
+
pygame.draw.polygon(ui_surf, arrow_col, [(170, 83), (176, 83), (173, 88)])
|
|
1534
|
+
|
|
1535
|
+
pygame.draw.rect(ui_surf, (230, 230, 230), (15, 105, 12, 12))
|
|
1536
|
+
pygame.draw.rect(ui_surf, (150, 150, 150), (15, 105, 12, 12), 1)
|
|
1537
|
+
txt_del_dup = font.render("Delete Duplicates", True, (50, 50, 50))
|
|
1538
|
+
ui_surf.blit(txt_del_dup, (35, 103))
|
|
1539
|
+
|
|
1540
|
+
panel_title = font_bold.render("Subfolders Panel", True, (0, 0, 0))
|
|
1541
|
+
ui_surf.blit(panel_title, (15, 130))
|
|
1542
|
+
|
|
1543
|
+
sy = 155
|
|
1544
|
+
for i, item in enumerate(subfolders):
|
|
1545
|
+
if sy + 25 > win_h:
|
|
1546
|
+
break
|
|
1547
|
+
is_parent = (item == "..")
|
|
1548
|
+
display_name = "[Parent Folder] .." if is_parent else f"📁 {item}"
|
|
1549
|
+
color = (0, 102, 204) if is_parent else (50, 50, 50)
|
|
1550
|
+
|
|
1551
|
+
if mx <= sidebar_w and sy <= my <= sy + 22 and (not state['dropdown_open'] or my < 102 or my > 102 + min(350, win_h - 120)):
|
|
1552
|
+
pygame.draw.rect(ui_surf, (230, 240, 250), (10, sy - 2, sidebar_w - 20, 22))
|
|
1553
|
+
pygame.draw.rect(ui_surf, (180, 200, 230), (10, sy - 2, sidebar_w - 20, 22), 1)
|
|
1554
|
+
|
|
1555
|
+
txt = font.render(display_name, True, color)
|
|
1556
|
+
ui_surf.blit(txt, (20, sy))
|
|
1557
|
+
sy += 25
|
|
1558
|
+
|
|
1559
|
+
pygame.draw.rect(ui_surf, (255, 255, 255), (sidebar_w + 15, 30, grid_w - 120, 30))
|
|
1560
|
+
pygame.draw.rect(ui_surf, (200, 200, 200), (sidebar_w + 15, 30, grid_w - 120, 30), 1)
|
|
1561
|
+
addr_txt = font.render(f" {state['current_folder']}", True, (80, 80, 80))
|
|
1562
|
+
ui_surf.blit(addr_txt, (sidebar_w + 20, 36))
|
|
1563
|
+
|
|
1564
|
+
pygame.draw.rect(ui_surf, (255, 255, 255), (win_w - 90, 30, 75, 30))
|
|
1565
|
+
pygame.draw.rect(ui_surf, (200, 200, 200), (win_w - 90, 30, 75, 30), 1)
|
|
1566
|
+
search_txt = font.render("Search", True, (150, 150, 150))
|
|
1567
|
+
ui_surf.blit(search_txt, (win_w - 80, 36))
|
|
1568
|
+
|
|
1569
|
+
if state['fullscreen_index'] == -1 and state['max_scroll'] > 0:
|
|
1570
|
+
bar_h = max(30, int((grid_h / (state['max_scroll'] + grid_h)) * grid_h))
|
|
1571
|
+
bar_y = top_bar_h + int((state['scroll_y'] / state['max_scroll']) * (grid_h - bar_h))
|
|
1572
|
+
pygame.draw.rect(ui_surf, (240, 240, 240), (win_w - 12, top_bar_h, 12, grid_h))
|
|
1573
|
+
pygame.draw.rect(ui_surf, (200, 200, 200), (win_w - 10, bar_y, 8, bar_h), border_radius=4)
|
|
1574
|
+
|
|
1575
|
+
if state['dropdown_open'] and state['sort_dropdown_enabled']:
|
|
1576
|
+
all_options = ["Default"] + state['sort_headers']
|
|
1577
|
+
dropdown_max_h = min(350, win_h - 120)
|
|
1578
|
+
opt_h = 20
|
|
1579
|
+
|
|
1580
|
+
pygame.draw.rect(ui_surf, (255, 255, 255), (15, 102, 170, dropdown_max_h))
|
|
1581
|
+
pygame.draw.rect(ui_surf, (150, 150, 150), (15, 102, 170, dropdown_max_h), 1)
|
|
1582
|
+
|
|
1583
|
+
clip_prev = ui_surf.get_clip()
|
|
1584
|
+
ui_surf.set_clip(pygame.Rect(16, 103, 168, dropdown_max_h - 2))
|
|
1585
|
+
|
|
1586
|
+
for idx, opt in enumerate(all_options):
|
|
1587
|
+
oy = 102 + idx * opt_h - state['dropdown_scroll']
|
|
1588
|
+
if oy + opt_h < 102 or oy > 102 + dropdown_max_h:
|
|
1589
|
+
continue
|
|
1590
|
+
|
|
1591
|
+
is_opt_hov = pygame.Rect(15, oy, 170, opt_h).collidepoint(mx, my) and pygame.Rect(15, 102, 170, dropdown_max_h).collidepoint(mx, my)
|
|
1592
|
+
if is_opt_hov:
|
|
1593
|
+
pygame.draw.rect(ui_surf, (230, 240, 250), (16, oy, 168, opt_h))
|
|
1594
|
+
|
|
1595
|
+
opt_txt = font.render(opt, True, (40, 40, 40))
|
|
1596
|
+
ui_surf.blit(opt_txt, (22, oy + 3))
|
|
1597
|
+
|
|
1598
|
+
ui_surf.set_clip(clip_prev)
|
|
1599
|
+
|
|
1600
|
+
if state['context_menu_open']:
|
|
1601
|
+
cx, cy = state['context_menu_pos']
|
|
1602
|
+
pygame.draw.rect(ui_surf, (255, 255, 255), (cx, cy, 120, 80))
|
|
1603
|
+
pygame.draw.rect(ui_surf, (150, 150, 150), (cx, cy, 120, 80), 1)
|
|
1604
|
+
|
|
1605
|
+
if pygame.Rect(cx, cy, 120, 20).collidepoint(mx, my):
|
|
1606
|
+
pygame.draw.rect(ui_surf, (230, 240, 250), (cx + 1, cy + 1, 118, 18))
|
|
1607
|
+
elif pygame.Rect(cx, cy + 20, 120, 20).collidepoint(mx, my):
|
|
1608
|
+
pygame.draw.rect(ui_surf, (230, 240, 250), (cx + 1, cy + 21, 118, 18))
|
|
1609
|
+
elif pygame.Rect(cx, cy + 40, 120, 20).collidepoint(mx, my):
|
|
1610
|
+
pygame.draw.rect(ui_surf, (230, 240, 250), (cx + 1, cy + 41, 118, 18))
|
|
1611
|
+
elif pygame.Rect(cx, cy + 60, 120, 20).collidepoint(mx, my):
|
|
1612
|
+
pygame.draw.rect(ui_surf, (230, 240, 250), (cx + 1, cy + 61, 118, 18))
|
|
1613
|
+
|
|
1614
|
+
txt_rename = font.render("Rename", True, (40, 40, 40))
|
|
1615
|
+
txt_delete = font.render("Delete", True, (40, 40, 40))
|
|
1616
|
+
txt_stella = font.render("Open in Stella", True, (40, 40, 40))
|
|
1617
|
+
txt_view_source = font.render("View Source", True, (40, 40, 40))
|
|
1618
|
+
|
|
1619
|
+
ui_surf.blit(txt_rename, (cx + 10, cy + 3))
|
|
1620
|
+
ui_surf.blit(txt_delete, (cx + 10, cy + 23))
|
|
1621
|
+
ui_surf.blit(txt_stella, (cx + 10, cy + 43))
|
|
1622
|
+
ui_surf.blit(txt_view_source, (cx + 10, cy + 63))
|
|
1623
|
+
|
|
1624
|
+
return ui_surf
|
|
1625
|
+
|
|
1626
|
+
def main():
|
|
1627
|
+
import subprocess
|
|
1628
|
+
|
|
1629
|
+
pygame.init()
|
|
1630
|
+
pygame.font.init()
|
|
1631
|
+
|
|
1632
|
+
win_w, win_h = 1300, 750
|
|
1633
|
+
|
|
1634
|
+
try:
|
|
1635
|
+
screen_w, screen_h = 1920, 1080 # Default fallback
|
|
1636
|
+
if sys.platform == 'win32':
|
|
1637
|
+
import ctypes
|
|
1638
|
+
screen_w = ctypes.windll.user32.GetSystemMetrics(0)
|
|
1639
|
+
screen_h = ctypes.windll.user32.GetSystemMetrics(1)
|
|
1640
|
+
else:
|
|
1641
|
+
info = pygame.display.Info()
|
|
1642
|
+
screen_w = info.current_w
|
|
1643
|
+
screen_h = info.current_h
|
|
1644
|
+
|
|
1645
|
+
# Center the window on the desktop
|
|
1646
|
+
x_pos = max(0, (screen_w - win_w) // 2)
|
|
1647
|
+
y_pos = max(0, (screen_h - win_h) // 2 - 30)
|
|
1648
|
+
os.environ['SDL_VIDEO_WINDOW_POS'] = f"{x_pos},{y_pos}"
|
|
1649
|
+
except Exception as e:
|
|
1650
|
+
print(f"Error setting window position: {e}")
|
|
1651
|
+
|
|
1652
|
+
pygame.display.gl_set_attribute(pygame.GL_DEPTH_SIZE, 24)
|
|
1653
|
+
pygame.display.set_mode((win_w, win_h), pygame.OPENGL | pygame.DOUBLEBUF | pygame.RESIZABLE)
|
|
1654
|
+
|
|
1655
|
+
ctx = moderngl.create_context()
|
|
1656
|
+
ctx.enable(moderngl.DEPTH_TEST)
|
|
1657
|
+
ctx.enable(moderngl.BLEND)
|
|
1658
|
+
ctx.blend_func = (moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA)
|
|
1659
|
+
|
|
1660
|
+
prog_3d, prog_2d, quad_vao = init_gl_resources(ctx)
|
|
1661
|
+
|
|
1662
|
+
font = pygame.font.SysFont('segoeui', 12)
|
|
1663
|
+
font_bold = pygame.font.SysFont('segoeui', 13, bold=True)
|
|
1664
|
+
|
|
1665
|
+
current_folder = os.path.abspath('.')
|
|
1666
|
+
target_file = None
|
|
1667
|
+
fullscreen_index = -1
|
|
1668
|
+
|
|
1669
|
+
if len(sys.argv) > 1:
|
|
1670
|
+
arg = sys.argv[1]
|
|
1671
|
+
if os.path.isfile(arg):
|
|
1672
|
+
current_folder = os.path.dirname(os.path.abspath(arg))
|
|
1673
|
+
target_file = os.path.basename(arg)
|
|
1674
|
+
elif os.path.isdir(arg):
|
|
1675
|
+
current_folder = os.path.abspath(arg)
|
|
1676
|
+
|
|
1677
|
+
viewers = load_viewers_from_folder(current_folder, ctx, prog_3d)
|
|
1678
|
+
subfolders = get_subfolders(current_folder)
|
|
1679
|
+
|
|
1680
|
+
# Central application UI & interactive state
|
|
1681
|
+
state = {
|
|
1682
|
+
'current_folder': current_folder,
|
|
1683
|
+
'target_file': target_file,
|
|
1684
|
+
'fullscreen_index': fullscreen_index,
|
|
1685
|
+
'active_face_index': -1,
|
|
1686
|
+
'active_vertex_index': -1,
|
|
1687
|
+
'edge_mode': 0,
|
|
1688
|
+
'active_report': None,
|
|
1689
|
+
'scroll_y': 0.0,
|
|
1690
|
+
'max_scroll': 0,
|
|
1691
|
+
'running': True,
|
|
1692
|
+
'hovered_index': -1,
|
|
1693
|
+
'last_click_time': 0,
|
|
1694
|
+
'dragging': False,
|
|
1695
|
+
'dragged_index': -1,
|
|
1696
|
+
'scrollbar_dragging': False,
|
|
1697
|
+
'scrollbar_click_offset': 0,
|
|
1698
|
+
'auto_rotate': False,
|
|
1699
|
+
'sort_dropdown_enabled': False,
|
|
1700
|
+
'sort_headers': [],
|
|
1701
|
+
'selected_sort_metric': "Default",
|
|
1702
|
+
'dropdown_open': False,
|
|
1703
|
+
'dropdown_scroll': 0,
|
|
1704
|
+
'context_menu_open': False,
|
|
1705
|
+
'context_menu_index': -1,
|
|
1706
|
+
'context_menu_pos': (0, 0),
|
|
1707
|
+
'needs_redraw': 2,
|
|
1708
|
+
'checker_proc': None,
|
|
1709
|
+
'item_w': 180
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if target_file:
|
|
1713
|
+
for idx, v in enumerate(viewers):
|
|
1714
|
+
if v.filename == target_file:
|
|
1715
|
+
fullscreen_index = idx
|
|
1716
|
+
state['fullscreen_index'] = idx
|
|
1717
|
+
run_single_analysis(v.filepath, state)
|
|
1718
|
+
break
|
|
1719
|
+
|
|
1720
|
+
sidebar_w = 200
|
|
1721
|
+
top_bar_h = 75
|
|
1722
|
+
padding = 25
|
|
1723
|
+
text_area_h = 45
|
|
1724
|
+
|
|
1725
|
+
clock = pygame.time.Clock()
|
|
1726
|
+
run_bg_analysis(state['current_folder'], viewers)
|
|
1727
|
+
|
|
1728
|
+
while state['running']:
|
|
1729
|
+
dt = clock.tick(60)
|
|
1730
|
+
dt = min(100, dt)
|
|
1731
|
+
|
|
1732
|
+
mx, my = pygame.mouse.get_pos()
|
|
1733
|
+
keys = pygame.key.get_pressed()
|
|
1734
|
+
keyboard_active = any(keys[k] for k in (K_LEFT, K_RIGHT, K_UP, K_DOWN))
|
|
1735
|
+
|
|
1736
|
+
if state['auto_rotate'] or keyboard_active or bg_csv_ready[0] or state['dragging'] or state['scrollbar_dragging']:
|
|
1737
|
+
state['needs_redraw'] = 2
|
|
1738
|
+
|
|
1739
|
+
events = pygame.event.get()
|
|
1740
|
+
if len(events) > 0:
|
|
1741
|
+
state['needs_redraw'] = 2
|
|
1742
|
+
|
|
1743
|
+
handle_events(events, state, viewers, subfolders, ctx, prog_3d)
|
|
1744
|
+
|
|
1745
|
+
# Dynamic layout calculations based on current window size
|
|
1746
|
+
win_w, win_h = pygame.display.get_window_size()
|
|
1747
|
+
fb_w, fb_h = win_w, win_h
|
|
1748
|
+
sx = 1.0
|
|
1749
|
+
sy = 1.0
|
|
1750
|
+
|
|
1751
|
+
grid_w = win_w - sidebar_w
|
|
1752
|
+
grid_h = win_h - top_bar_h
|
|
1753
|
+
|
|
1754
|
+
item_w = state.get('item_w', 180)
|
|
1755
|
+
item_h = item_w + text_area_h
|
|
1756
|
+
|
|
1757
|
+
cols = max(1, (grid_w - padding) // (item_w + padding))
|
|
1758
|
+
total_rows = math.ceil(len(viewers) / cols)
|
|
1759
|
+
state['max_scroll'] = max(0, total_rows * (item_h + padding) + padding - grid_h)
|
|
1760
|
+
state['scroll_y'] = max(0.0, min(state['scroll_y'], state['max_scroll']))
|
|
1761
|
+
|
|
1762
|
+
try:
|
|
1763
|
+
off_files_count = len([f for f in os.listdir(state['current_folder']) if f.lower().endswith('.off')])
|
|
1764
|
+
except Exception:
|
|
1765
|
+
off_files_count = 0
|
|
1766
|
+
|
|
1767
|
+
if bg_csv_ready[0]:
|
|
1768
|
+
csv_path = bg_csv_path[0]
|
|
1769
|
+
bg_csv_ready[0] = False
|
|
1770
|
+
try:
|
|
1771
|
+
if csv_path and os.path.exists(csv_path):
|
|
1772
|
+
with open(csv_path, 'r', encoding='utf-8') as f:
|
|
1773
|
+
reader = csv.DictReader(f)
|
|
1774
|
+
rows = list(reader)
|
|
1775
|
+
if reader.fieldnames:
|
|
1776
|
+
state['sort_headers'] = [h for h in reader.fieldnames if h not in ("Filename", "Detail")]
|
|
1777
|
+
metrics_map = {row["Filename"]: row for row in rows}
|
|
1778
|
+
for v in viewers:
|
|
1779
|
+
v.metrics = metrics_map.get(v.filename, {})
|
|
1780
|
+
state['sort_dropdown_enabled'] = True
|
|
1781
|
+
state['needs_redraw'] = 2
|
|
1782
|
+
except Exception as e:
|
|
1783
|
+
print("Error loading CSV results:", e)
|
|
1784
|
+
|
|
1785
|
+
# Process rendering only if marked as dirty
|
|
1786
|
+
if state['needs_redraw'] > 0:
|
|
1787
|
+
if state['fullscreen_index'] != -1:
|
|
1788
|
+
pygame.display.set_caption("Enlarged View")
|
|
1789
|
+
else:
|
|
1790
|
+
pygame.display.set_caption("Grid View")
|
|
1791
|
+
|
|
1792
|
+
active_viewer = None
|
|
1793
|
+
if state['fullscreen_index'] != -1:
|
|
1794
|
+
if 0 <= state['fullscreen_index'] < len(viewers):
|
|
1795
|
+
active_viewer = viewers[state['fullscreen_index']]
|
|
1796
|
+
elif state['hovered_index'] != -1:
|
|
1797
|
+
if 0 <= state['hovered_index'] < len(viewers):
|
|
1798
|
+
active_viewer = viewers[state['hovered_index']]
|
|
1799
|
+
|
|
1800
|
+
if active_viewer and not state['dragging']:
|
|
1801
|
+
rot_val = 0.003 * dt
|
|
1802
|
+
dx = 0.0
|
|
1803
|
+
dy = 0.0
|
|
1804
|
+
if keys[K_LEFT]:
|
|
1805
|
+
dx = -rot_val
|
|
1806
|
+
if keys[K_RIGHT]:
|
|
1807
|
+
dx = rot_val
|
|
1808
|
+
if keys[K_UP]:
|
|
1809
|
+
dy = -rot_val
|
|
1810
|
+
if keys[K_DOWN]:
|
|
1811
|
+
dy = rot_val
|
|
1812
|
+
active_viewer.rotate_camera(dx, dy)
|
|
1813
|
+
|
|
1814
|
+
if state['auto_rotate']:
|
|
1815
|
+
rot_val = AUTO_ROTATION_SPEED * dt
|
|
1816
|
+
for v in viewers:
|
|
1817
|
+
v.rotate_camera(rot_val, 0.0)
|
|
1818
|
+
|
|
1819
|
+
state['hovered_index'] = -1
|
|
1820
|
+
if state['fullscreen_index'] == -1:
|
|
1821
|
+
for i in range(len(viewers)):
|
|
1822
|
+
row = i // cols
|
|
1823
|
+
col = i % cols
|
|
1824
|
+
x = sidebar_w + padding + col * (item_w + padding)
|
|
1825
|
+
y = top_bar_h + padding + row * (item_h + padding) - int(state['scroll_y'])
|
|
1826
|
+
if x <= mx <= x + item_w and y <= my <= y + item_h:
|
|
1827
|
+
if mx > sidebar_w and my > top_bar_h:
|
|
1828
|
+
state['hovered_index'] = i
|
|
1829
|
+
break
|
|
1830
|
+
|
|
1831
|
+
ctx.clear(1.0, 1.0, 1.0, 1.0)
|
|
1832
|
+
|
|
1833
|
+
if state['fullscreen_index'] != -1:
|
|
1834
|
+
if 0 <= state['fullscreen_index'] < len(viewers):
|
|
1835
|
+
v = viewers[state['fullscreen_index']]
|
|
1836
|
+
|
|
1837
|
+
viewport_x = sidebar_w + 10
|
|
1838
|
+
v_y_gl = 45
|
|
1839
|
+
|
|
1840
|
+
# Allocate comfortable spacing based on active report
|
|
1841
|
+
report_active = bool(state.get('active_report'))
|
|
1842
|
+
viewport_w = win_w - viewport_x - 440 - 30 if report_active else win_w - viewport_x - 20
|
|
1843
|
+
viewport_h = win_h - top_bar_h - 60
|
|
1844
|
+
|
|
1845
|
+
# Model remains centered locally inside its designated viewport (target_x = 0.0)
|
|
1846
|
+
ctx.scissor = (int(viewport_x * sx), int(v_y_gl * sy), int(viewport_w * sx), int(viewport_h * sy))
|
|
1847
|
+
v.render_3d((int(viewport_x * sx), int(v_y_gl * sy), int(viewport_w * sx), int(viewport_h * sy)), viewport_w / max(1, viewport_h), state['active_face_index'], 0.0, state.get('active_vertex_index', -1), state.get('edge_mode', 0))
|
|
1848
|
+
else:
|
|
1849
|
+
for i, v in enumerate(viewers):
|
|
1850
|
+
row = i // cols
|
|
1851
|
+
col = i % cols
|
|
1852
|
+
|
|
1853
|
+
x = sidebar_w + padding + col * (item_w + padding)
|
|
1854
|
+
y_from_top = top_bar_h + padding + row * (item_h + padding) - int(state['scroll_y'])
|
|
1855
|
+
y_from_bottom = win_h - (y_from_top + item_h)
|
|
1856
|
+
|
|
1857
|
+
if y_from_top + item_h < top_bar_h or y_from_top > win_h:
|
|
1858
|
+
continue
|
|
1859
|
+
|
|
1860
|
+
grid_bottom_gl = 0
|
|
1861
|
+
grid_top_gl = win_h - top_bar_h
|
|
1862
|
+
|
|
1863
|
+
v_y_bottom = y_from_bottom + text_area_h
|
|
1864
|
+
v_y_top = y_from_bottom + item_h
|
|
1865
|
+
|
|
1866
|
+
clamped_bottom = max(grid_bottom_gl, v_y_bottom)
|
|
1867
|
+
clamped_top = min(grid_top_gl, v_y_top)
|
|
1868
|
+
|
|
1869
|
+
clamped_h = clamped_top - clamped_bottom
|
|
1870
|
+
if clamped_h > 0:
|
|
1871
|
+
ctx.scissor = (int((x + 1) * sx), int(clamped_bottom * sy), int((item_w - 2) * sx), int(clamped_h * sy))
|
|
1872
|
+
v.render_3d((int((x + 1) * sx), int(v_y_bottom * sy), int((item_w - 2) * sx), int(item_w * sy)), sx / sy, edge_mode=state.get('edge_mode', 0))
|
|
1873
|
+
|
|
1874
|
+
ctx.scissor = None
|
|
1875
|
+
ctx.viewport = (0, 0, int(fb_w), int(fb_h))
|
|
1876
|
+
|
|
1877
|
+
ui_surf = draw_ui_surface(win_w, win_h, state, viewers, subfolders, font, font_bold, mx, my, off_files_count)
|
|
1878
|
+
|
|
1879
|
+
ui_data = pygame.image.tostring(ui_surf, 'RGBA', True)
|
|
1880
|
+
ui_tex = ctx.texture((win_w, win_h), 4, ui_data)
|
|
1881
|
+
ui_tex.filter = (moderngl.NEAREST, moderngl.NEAREST)
|
|
1882
|
+
ui_tex.use(0)
|
|
1883
|
+
|
|
1884
|
+
quad_vao.render(moderngl.TRIANGLES)
|
|
1885
|
+
|
|
1886
|
+
pygame.display.flip()
|
|
1887
|
+
ui_tex.release()
|
|
1888
|
+
|
|
1889
|
+
state['needs_redraw'] = max(0, state['needs_redraw'] - 1)
|
|
1890
|
+
else:
|
|
1891
|
+
pygame.time.wait(15)
|
|
1892
|
+
|
|
1893
|
+
if state.get('checker_proc'):
|
|
1894
|
+
try:
|
|
1895
|
+
state['checker_proc'].terminate()
|
|
1896
|
+
except Exception:
|
|
1897
|
+
pass
|
|
1898
|
+
|
|
1899
|
+
if bg_csv_path[0] and os.path.exists(bg_csv_path[0]):
|
|
1900
|
+
try:
|
|
1901
|
+
os.remove(bg_csv_path[0])
|
|
1902
|
+
except Exception:
|
|
1903
|
+
pass
|
|
1904
|
+
|
|
1905
|
+
pygame.quit()
|
|
1906
|
+
|
|
1907
|
+
if __name__ == '__main__':
|
|
1908
|
+
main()
|