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/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()