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 ADDED
@@ -0,0 +1,869 @@
1
+ """This file can be used to get the properties of an object decribed in the OFF format."""
2
+ import sys
3
+ import os
4
+ import tkinter as tk
5
+ from tkinter import filedialog, messagebox, scrolledtext
6
+ from collections import defaultdict
7
+ import math
8
+ import windnd
9
+ import ctypes
10
+ import csv
11
+ import importlib.util
12
+ import re
13
+ import heapq
14
+ import numpy as np
15
+
16
+ def calculate_face_normal_and_area(coords):
17
+ """Calculates the area and the Newell normal of a 3D polygon, handling self-intersections for absolute area."""
18
+ if len(coords) < 3:
19
+ return (0, 0, 0), 0.0
20
+ nx = ny = nz = 0.0
21
+ for i in range(len(coords)):
22
+ v_curr = coords[i]
23
+ v_next = coords[(i + 1) % len(coords)]
24
+ nx += (v_curr[1] - v_next[1]) * (v_curr[2] + v_next[2])
25
+ ny += (v_curr[2] - v_next[2]) * (v_curr[0] + v_next[0])
26
+ nz += (v_curr[0] - v_next[0]) * (v_curr[1] + v_next[1])
27
+
28
+ length = math.sqrt(nx**2 + ny**2 + nz**2)
29
+ if length == 0:
30
+ return (0, 0, 0), 0.0
31
+ normal = (nx/length, ny/length, nz/length)
32
+
33
+ # Helper to project 3D coordinates to 2D isometry
34
+ def project_to_2d_isometry(pts, n_vec):
35
+ n_arr = np.array(n_vec)
36
+ if abs(n_arr[0]) < 0.9:
37
+ ref = np.array([1.0, 0.0, 0.0])
38
+ else:
39
+ ref = np.array([0.0, 1.0, 0.0])
40
+ u = np.cross(n_arr, ref)
41
+ u /= np.linalg.norm(u)
42
+ v = np.cross(n_arr, u)
43
+ return [np.array([np.dot(p, u), np.dot(p, v)]) for p in pts]
44
+
45
+ # Helper to compute absolute area of possibly self-intersecting 2D polygon
46
+ def compute_absolute_area_2d(p2d):
47
+ n_pts = len(p2d)
48
+ for i in range(n_pts):
49
+ for j in range(i + 2, n_pts):
50
+ if i == 0 and j == n_pts - 1:
51
+ continue
52
+ A = p2d[i]
53
+ B = p2d[(i + 1) % n_pts]
54
+ C = p2d[j]
55
+ D = p2d[(j + 1) % n_pts]
56
+
57
+ r = B - A
58
+ s = D - C
59
+ denom = r[0]*s[1] - r[1]*s[0]
60
+ if abs(denom) > 1e-12:
61
+ num_t = (C[0]-A[0])*s[1] - (C[1]-A[1])*s[0]
62
+ num_u = (C[0]-A[0])*r[1] - (C[1]-A[1])*r[0]
63
+ t = num_t / denom
64
+ u = num_u / denom
65
+ if 1e-11 < t < 1.0 - 1e-11 and 1e-11 < u < 1.0 - 1e-11:
66
+ P = A + t * r
67
+ loop1 = p2d[i+1 : j+1] + [P]
68
+ loop2 = p2d[j+1:] + p2d[:i+1] + [P]
69
+ return compute_absolute_area_2d(loop1) + compute_absolute_area_2d(loop2)
70
+
71
+ area = 0.0
72
+ for i in range(n_pts):
73
+ p1 = p2d[i]
74
+ p2 = p2d[(i + 1) % n_pts]
75
+ area += p1[0] * p2[1] - p2[0] * p1[1]
76
+ return 0.5 * abs(area)
77
+
78
+ p2d_proj = project_to_2d_isometry(coords, normal)
79
+ abs_area = compute_absolute_area_2d(p2d_proj)
80
+ return normal, abs_area
81
+
82
+ def get_convex_hull_normal(coords):
83
+ """Computes a stable plane normal by finding the 2D convex hull of projected 3D coordinates."""
84
+ if len(coords) < 3:
85
+ return (0.0, 0.0, 1.0)
86
+
87
+ pts = np.array(coords)
88
+ centroid = np.mean(pts, axis=0)
89
+ pts_centered = pts - centroid
90
+ cov = np.dot(pts_centered.T, pts_centered)
91
+ eigenvalues, eigenvectors = np.linalg.eigh(cov)
92
+ normal_pca = eigenvectors[:, 0]
93
+ norm = np.linalg.norm(normal_pca)
94
+ if norm > 1e-12:
95
+ normal_pca /= norm
96
+ else:
97
+ normal_pca = np.array([0.0, 0.0, 1.0])
98
+
99
+ if abs(normal_pca[0]) < 0.9:
100
+ ref = np.array([1.0, 0.0, 0.0])
101
+ else:
102
+ ref = np.array([0.0, 1.0, 0.0])
103
+ u = np.cross(normal_pca, ref)
104
+ u_norm = np.linalg.norm(u)
105
+ if u_norm > 1e-12:
106
+ u /= u_norm
107
+ v = np.cross(normal_pca, u)
108
+ v_norm = np.linalg.norm(v)
109
+ if v_norm > 1e-12:
110
+ v /= v_norm
111
+
112
+ pts_2d = []
113
+ for i, p in enumerate(coords):
114
+ px = np.dot(p - centroid, u)
115
+ py = np.dot(p - centroid, v)
116
+ pts_2d.append((px, py, i))
117
+
118
+ sorted_pts = sorted(pts_2d, key=lambda p: (p[0], p[1]))
119
+ if len(sorted_pts) < 3:
120
+ return tuple(normal_pca)
121
+
122
+ def cross_product(o, a, b):
123
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
124
+
125
+ lower = []
126
+ for p in sorted_pts:
127
+ while len(lower) >= 2 and cross_product(lower[-2], lower[-1], p) <= 1e-11:
128
+ lower.pop()
129
+ lower.append(p)
130
+
131
+ upper = []
132
+ for p in reversed(sorted_pts):
133
+ while len(upper) >= 2 and cross_product(upper[-2], upper[-1], p) <= 1e-11:
134
+ upper.pop()
135
+ upper.append(p)
136
+
137
+ hull_pts_2d = lower[:-1] + upper[:-1]
138
+ hull_coords = [coords[p[2]] for p in hull_pts_2d]
139
+
140
+ hull_normal, _ = calculate_face_normal_and_area(hull_coords)
141
+ return hull_normal
142
+
143
+ def is_concave_face(coords, face_normal):
144
+ """Checks if a face is non-convex."""
145
+ if len(coords) < 4:
146
+ return False
147
+
148
+ for i in range(len(coords)):
149
+ v_prev = coords[i - 1]
150
+ v_curr = coords[i]
151
+ v_next = coords[(i + 1) % len(coords)]
152
+
153
+ e1 = (v_curr[0] - v_prev[0], v_curr[1] - v_prev[1], v_curr[2] - v_prev[2])
154
+ e2 = (v_next[0] - v_curr[0], v_next[1] - v_curr[1], v_next[2] - v_curr[2])
155
+
156
+ local_nx = e1[1] * e2[2] - e1[2] * e2[1]
157
+ local_ny = e1[2] * e2[0] - e1[0] * e2[2]
158
+ local_nz = e1[0] * e2[1] - e1[1] * e2[0]
159
+
160
+ dot = local_nx * face_normal[0] + local_ny * face_normal[1] + local_nz * face_normal[2]
161
+ if dot < -1e-12:
162
+ return True
163
+ return False
164
+
165
+ def is_crossed_face(coords, normal):
166
+ """Checks if a face is self-intersecting (crossed) by projecting to 2D."""
167
+ if len(coords) < 4:
168
+ return False
169
+
170
+ nx, ny, nz = normal
171
+ abs_x, abs_y, abs_z = abs(nx), abs(ny), abs(nz)
172
+ if abs_z >= abs_x and abs_z >= abs_y:
173
+ p2d = [(v[0], v[1]) for v in coords]
174
+ elif abs_y >= abs_x:
175
+ p2d = [(v[0], v[2]) for v in coords]
176
+ else:
177
+ p2d = [(v[1], v[2]) for v in coords]
178
+
179
+ n = len(p2d)
180
+ for i in range(n):
181
+ for j in range(i + 2, n):
182
+ if i == 0 and j == n - 1:
183
+ continue
184
+ A = p2d[i]
185
+ B = p2d[(i + 1) % n]
186
+ C = p2d[j]
187
+ D = p2d[(j + 1) % n]
188
+
189
+ def cp(p1, p2, p3):
190
+ return (p2[0] - p1[0]) * (p3[1] - p1[1]) - (p2[1] - p1[1]) * (p3[0] - p1[0])
191
+ d1 = cp(C, D, A)
192
+ d2 = cp(C, D, B)
193
+ d3 = cp(A, B, C)
194
+ d4 = cp(A, B, D)
195
+ if (((d1 > 1e-12 and d2 < -1e-12) or (d1 < -1e-12 and d2 > 1e-12)) and
196
+ ((d3 > 1e-12 and d4 < -1e-12) or (d3 < -1e-12 and d4 > 1e-12))):
197
+ return True
198
+ return False
199
+
200
+ def check_planarity(coords, normal, tolerance=1e-6):
201
+ """Checks if all vertices of a face lie on the same plane."""
202
+ if len(coords) <= 3:
203
+ return False, 0.0 # Triangles are always planar
204
+
205
+ v0 = coords[0]
206
+ max_dist = 0.0
207
+ for i in range(1, len(coords)):
208
+ vec = (coords[i][0] - v0[0], coords[i][1] - v0[1], coords[i][2] - v0[2])
209
+ dist = abs(vec[0]*normal[0] + vec[1]*normal[1] + vec[2]*normal[2])
210
+ max_dist = max_dist if max_dist > dist else dist
211
+
212
+ return max_dist > tolerance, max_dist
213
+
214
+ def find_closest_vertex_distance(unique_vertices, initial_estimate):
215
+ """Calculates the absolute closest distance between any two distinct vertices."""
216
+ if len(unique_vertices) < 2:
217
+ return float('inf')
218
+
219
+ d_min = initial_estimate if initial_estimate > 0 else float('inf')
220
+ pts = sorted(unique_vertices, key=lambda p: p[0])
221
+ n = len(pts)
222
+
223
+ for i in range(n):
224
+ for j in range(i + 1, n):
225
+ dx = pts[j][0] - pts[i][0]
226
+ if dx >= d_min:
227
+ break
228
+ dy = pts[j][1] - pts[i][1]
229
+ dz = pts[j][2] - pts[i][2]
230
+ dist = math.sqrt(dx*dx + dy*dy + dz*dz)
231
+ if 0 < dist < d_min:
232
+ d_min = dist
233
+ return d_min
234
+
235
+ def verify_off_logic(filepath, return_stats=False, run_symmetry=True):
236
+ """The core consistency checking logic."""
237
+ one_vertex_faces_detected = False
238
+ try:
239
+ with open(filepath, 'r') as f:
240
+ lines = [line.strip() for line in f if line.strip() and not line.strip().startswith('#')]
241
+ except Exception as e:
242
+ err = f"Error opening file: {e}"
243
+ return (err, None) if return_stats else err
244
+
245
+ if not lines or not lines[0].startswith('OFF'):
246
+ err = "Error: Valid OFF header not found."
247
+ return (err, None) if return_stats else err
248
+
249
+ header_idx = 1 if lines[0] == 'OFF' else 0
250
+ count_line = lines[header_idx]
251
+ if header_idx == 0: count_line = count_line[3:].strip()
252
+
253
+ try:
254
+ counts = list(map(int, count_line.split()))
255
+ num_vertices, num_faces = counts[0], counts[1]
256
+ except:
257
+ err = "Error: Could not parse vertex/face counts from header."
258
+ return (err, None) if return_stats else err
259
+
260
+ data_start_idx = header_idx + 1
261
+ vertex_end_idx = data_start_idx + num_vertices
262
+
263
+ if len(lines) < vertex_end_idx:
264
+ err = f"Error: Header claims {num_vertices} vertices, but file ends prematurely."
265
+ return (err, None) if return_stats else err
266
+
267
+ # --- 1. Parse Vertices ---
268
+ vertices = []
269
+ vertex_positions = defaultdict(list)
270
+ duplicate_coords_count = 0
271
+ max_decimal_places = 0
272
+
273
+ for i in range(data_start_idx, vertex_end_idx):
274
+ try:
275
+ raw_parts = lines[i].split()[:3]
276
+ for part in raw_parts:
277
+ if '.' in part:
278
+ dec_part = part.split('.')[1].split('e')[0].split('E')[0]
279
+ max_decimal_places = max(max_decimal_places, len(dec_part))
280
+
281
+ coords = tuple(map(float, raw_parts))
282
+ vertices.append(coords)
283
+ vertex_positions[coords].append(i - data_start_idx)
284
+ except ValueError:
285
+ err = f"Error: Could not parse coordinates at vertex line {i}."
286
+ return (err, None) if return_stats else err
287
+
288
+ for pos in vertex_positions:
289
+ if len(vertex_positions[pos]) > 1:
290
+ duplicate_coords_count += 1
291
+
292
+ # Calculate distance to origin for each vertex
293
+ origin_distances = [math.sqrt(v[0]**2 + v[1]**2 + v[2]**2) for v in vertices]
294
+ max_origin_dist = max(origin_distances) if origin_distances else 0.0
295
+ min_origin_dist = min(origin_distances) if origin_distances else 0.0
296
+
297
+ # --- 2. Parse Faces ---
298
+ edge_dict = defaultdict(int)
299
+ vertex_valence_map = [set() for _ in range(num_vertices)]
300
+ face_type_counts = defaultdict(int)
301
+ face_lines = lines[vertex_end_idx : vertex_end_idx + num_faces]
302
+ zero_area_faces = []
303
+ hemihedral_faces = []
304
+ faces_with_repeats = []
305
+ crossed_faces = []
306
+ concave_faces = []
307
+ non_planar_faces = []
308
+ min_face_area = float('inf')
309
+ shortest_edge_len = float('inf')
310
+ max_planar_err = 0.0
311
+ TOLERANCE = 1e-12
312
+
313
+ processed_face_count = 0
314
+ parsed_faces = []
315
+
316
+ for i, line in enumerate(face_lines):
317
+ raw_parts = line.split()
318
+ if not raw_parts: continue
319
+ try:
320
+ n_v = int(raw_parts[0])
321
+ v_indices = list(map(int, raw_parts[1:1+n_v]))
322
+ except ValueError:
323
+ err = f"Error: Face {i} has non-integer indices."
324
+ return (err, None) if return_stats else err
325
+
326
+ if len(v_indices) != n_v:
327
+ err = f"Error: Face {i} claims {n_v} vertices but lists {len(v_indices)}."
328
+ return (err, None) if return_stats else err
329
+
330
+ parsed_faces.append(v_indices)
331
+ face_type_counts[n_v] += 1
332
+ processed_face_count += 1
333
+
334
+ if len(set(v_indices)) != len(v_indices):
335
+ faces_with_repeats.append(i)
336
+
337
+ face_coords = []
338
+ for j in range(n_v):
339
+ v_idx = v_indices[j]
340
+ v_next_idx = v_indices[(j + 1) % n_v]
341
+ if v_idx < 0 or v_idx >= num_vertices:
342
+ err = f"Error: Face {i} references invalid vertex index {v_idx}."
343
+ return (err, None) if return_stats else err
344
+
345
+ v1 = vertices[v_idx]
346
+ face_coords.append(v1)
347
+ v2 = vertices[v_next_idx]
348
+ dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(v1, v2)))
349
+ if dist > 0:
350
+ shortest_edge_len = min(shortest_edge_len, dist)
351
+
352
+ edge = tuple(sorted((v_idx, v_next_idx)))
353
+ edge_dict[edge] += 1
354
+ vertex_valence_map[v_idx].add(edge)
355
+ vertex_valence_map[v_next_idx].add(edge)
356
+
357
+ # Check for Hemihedra (centered at origin)
358
+ cx = sum(v[0] for v in face_coords) / n_v
359
+ cy = sum(v[1] for v in face_coords) / n_v
360
+ cz = sum(v[2] for v in face_coords) / n_v
361
+ if abs(cx) < TOLERANCE and abs(cy) < TOLERANCE and abs(cz) < TOLERANCE:
362
+ hemihedral_faces.append(i)
363
+
364
+ normal, area = calculate_face_normal_and_area(face_coords)
365
+ if processed_face_count == 1 or area < min_face_area:
366
+ min_face_area = area
367
+
368
+ if area < TOLERANCE:
369
+ zero_area_faces.append(i)
370
+ else:
371
+ if is_crossed_face(face_coords, normal):
372
+ crossed_faces.append(i)
373
+ if is_concave_face(face_coords, normal):
374
+ concave_faces.append(i)
375
+
376
+ # Using the stable normal of the face's convex hull for planarity checks
377
+ hull_normal = get_convex_hull_normal(face_coords)
378
+ is_np, err = check_planarity(face_coords, hull_normal)
379
+ max_planar_err = max(max_planar_err, err)
380
+ if is_np:
381
+ non_planar_faces.append(i)
382
+
383
+ # --- 3. Connected Components (Compound Parts) ---
384
+ edge_to_faces = defaultdict(list)
385
+ for f_idx, v_indices in enumerate(parsed_faces):
386
+ n_v = len(v_indices)
387
+ for j in range(n_v):
388
+ v_idx = v_indices[j]
389
+ v_next_idx = v_indices[(j + 1) % n_v]
390
+ edge = tuple(sorted((v_idx, v_next_idx)))
391
+ edge_to_faces[edge].append(f_idx)
392
+
393
+ face_neighbors = defaultdict(list)
394
+ for edge, f_indices in edge_to_faces.items():
395
+ if len(f_indices) > 1:
396
+ for f1 in f_indices:
397
+ for f2 in f_indices:
398
+ if f1 != f2:
399
+ face_neighbors[f1].append(f2)
400
+
401
+ actual_num_faces = len(parsed_faces)
402
+ visited_faces = [False] * actual_num_faces
403
+ compound_parts = 0
404
+ for i in range(actual_num_faces):
405
+ if not visited_faces[i]:
406
+ compound_parts += 1
407
+ queue = [i]
408
+ visited_faces[i] = True
409
+ head = 0
410
+ while head < len(queue):
411
+ curr = queue[head]
412
+ head += 1
413
+ for neighbor in face_neighbors[curr]:
414
+ if not visited_faces[neighbor]:
415
+ visited_faces[neighbor] = True
416
+ queue.append(neighbor)
417
+
418
+ boundary = [e for e, c in edge_dict.items() if c == 1]
419
+ non_manifold = [e for e, c in edge_dict.items() if c > 2]
420
+
421
+ valence_distribution = defaultdict(int)
422
+ for v_edges in vertex_valence_map:
423
+ valence_distribution[len(v_edges)] += 1
424
+
425
+ # Genus calculation: (2 - Euler Characteristic) / 2
426
+ euler_chi = num_vertices - len(edge_dict) + actual_num_faces
427
+ genus = (2 - euler_chi) / 2
428
+ if genus.is_integer():
429
+ genus = int(genus)
430
+
431
+ # Gonality: Populated if all faces have the same gonality
432
+ gonality = ""
433
+ if len(face_type_counts) == 1:
434
+ gonality = list(face_type_counts.keys())[0]
435
+
436
+ # Valence: Populated only if all vertices of input polyhedron have the same valence
437
+ shared_valence = ""
438
+ if len(valence_distribution) == 1 and num_vertices > 0:
439
+ shared_valence = list(valence_distribution.keys())[0]
440
+
441
+ # --- 3a. Geometric Path Metrics & Weighted Wiener Index scaled to R = 1 ---
442
+ # Find Centroid and Circumradius
443
+ if num_vertices > 0:
444
+ xs = [v[0] for v in vertices]
445
+ ys = [v[1] for v in vertices]
446
+ zs = [v[2] for v in vertices]
447
+ cx = sum(xs) / num_vertices
448
+ cy = sum(ys) / num_vertices
449
+ cz = sum(zs) / num_vertices
450
+
451
+ max_r_sq = 0.0
452
+ for v in vertices:
453
+ r_sq = (v[0] - cx)**2 + (v[1] - cy)**2 + (v[2] - cz)**2
454
+ if r_sq > max_r_sq:
455
+ max_r_sq = r_sq
456
+ circumradius = math.sqrt(max_r_sq) if max_r_sq > 0 else 1.0
457
+ else:
458
+ circumradius = 1.0
459
+
460
+ scale_factor = 1.0 / circumradius
461
+
462
+ # Scale the edge lengths
463
+ edge_lengths = {}
464
+ for (u, v), count in edge_dict.items():
465
+ v1 = vertices[u]
466
+ v2 = vertices[v]
467
+ dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(v1, v2)))
468
+ edge_lengths[(u, v)] = dist * scale_factor
469
+
470
+ adj_weighted = [[] for _ in range(num_vertices)]
471
+ for (u, v), length in edge_lengths.items():
472
+ adj_weighted[u].append((v, length))
473
+ adj_weighted[v].append((u, length))
474
+
475
+ total_geom_dist = 0
476
+ disconnected = False
477
+
478
+ for start in range(num_vertices):
479
+ dist_map = {start: 0.0}
480
+ pq = [(0.0, start)]
481
+ while pq:
482
+ d, curr = heapq.heappop(pq)
483
+ if d > dist_map[curr]:
484
+ continue
485
+ for nbr, weight in adj_weighted[curr]:
486
+ new_d = d + weight
487
+ if nbr not in dist_map or new_d < dist_map[nbr]:
488
+ dist_map[nbr] = new_d
489
+ heapq.heappush(pq, (new_d, nbr))
490
+
491
+ if len(dist_map) < num_vertices:
492
+ disconnected = True
493
+ for d_val in dist_map.values():
494
+ total_geom_dist += d_val
495
+
496
+ if not disconnected and num_vertices > 1:
497
+ wiener_index = total_geom_dist / 2
498
+ else:
499
+ wiener_index = float('nan')
500
+
501
+ # --- 4. Symmetry Group and Order ---
502
+ symmetry_order = ""
503
+ symmetry_symbol = ""
504
+ if run_symmetry:
505
+ symmetry_order = "N/A"
506
+ symmetry_symbol = "N/A"
507
+ try:
508
+ script_dir = os.path.dirname(os.path.abspath(__file__)) if '__file__' in globals() else os.getcwd()
509
+ sym_path = os.path.join(script_dir, "offcheck-symmetry.py")
510
+ if not os.path.exists(sym_path):
511
+ sym_path = os.path.abspath("offcheck-symmetry.py")
512
+
513
+ if os.path.exists(sym_path):
514
+ spec = importlib.util.spec_from_file_location("offcheck_symmetry", sym_path)
515
+ offcheck_symmetry = importlib.util.module_from_spec(spec)
516
+ spec.loader.exec_module(offcheck_symmetry)
517
+
518
+ if one_vertex_faces_detected:
519
+ temp_filepath = filepath + ".symtemp.off"
520
+ with open(temp_filepath, 'w') as temp_f:
521
+ temp_f.write("OFF\n")
522
+ temp_f.write(f"{num_vertices} {len(parsed_faces)} 0\n")
523
+ for v in vertices:
524
+ temp_f.write(f"{v[0]} {v[1]} {v[2]}\n")
525
+ for face in parsed_faces:
526
+ temp_f.write(f"{len(face)} " + " ".join(map(str, face)) + "\n")
527
+ target_filepath = temp_filepath
528
+ else:
529
+ target_filepath = filepath
530
+
531
+ try:
532
+ sym_report = offcheck_symmetry.analyze_symmetry(target_filepath)
533
+ if sym_report.startswith("Error"):
534
+ symmetry_order = "Error"
535
+ symmetry_symbol = sym_report
536
+ else:
537
+ for line in sym_report.splitlines():
538
+ if "TOTAL SYMMETRY GROUP ORDER:" in line:
539
+ symmetry_order = int(line.split()[-1])
540
+ elif "SCHOENFLIES SYMBOL:" in line:
541
+ symmetry_symbol = line.split()[-1]
542
+ except Exception as inner_e:
543
+ import traceback
544
+ symmetry_order = "Error in analysis"
545
+ symmetry_symbol = str(inner_e)
546
+ print(traceback.format_exc(), file=sys.stderr)
547
+ finally:
548
+ if one_vertex_faces_detected and os.path.exists(temp_filepath):
549
+ try:
550
+ os.remove(temp_filepath)
551
+ except Exception:
552
+ pass
553
+ else:
554
+ symmetry_order = "Missing helper file"
555
+ symmetry_symbol = "N/A"
556
+ except Exception as e:
557
+ import traceback
558
+ symmetry_order = "Load Error"
559
+ symmetry_symbol = str(e)
560
+ print(traceback.format_exc(), file=sys.stderr)
561
+
562
+ def format_metric(val):
563
+ if val == float('inf') or val == 0 or math.isnan(val): return "N/A"
564
+ if val < 1e-4: return f"{val:.8e}"
565
+ s = f"{val:.8f}".rstrip('0')
566
+ return s + "0" if s.endswith('.') else s
567
+
568
+ closest_vertex_dist = find_closest_vertex_distance(list(vertex_positions.keys()), shortest_edge_len)
569
+
570
+ disp_shortest_edge = format_metric(shortest_edge_len)
571
+ disp_closest_vertex = format_metric(closest_vertex_dist)
572
+ disp_min_area = format_metric(min_face_area)
573
+ disp_wiener = format_metric(wiener_index)
574
+ disp_max_origin_dist = format_metric(max_origin_dist)
575
+ disp_min_origin_dist = format_metric(min_origin_dist)
576
+
577
+ face_comp_report = []
578
+ for v_count in sorted(face_type_counts.keys()):
579
+ label = {3: "Triangles", 4: "Quads", 5: "Pentagons"}.get(v_count, f"{v_count}-gons")
580
+ face_comp_report.append(f"{label:<15} ({v_count} vts): {face_type_counts[v_count]}")
581
+
582
+ valence_report = []
583
+ for val in sorted(valence_distribution.keys()):
584
+ valence_report.append(f"Valence {val:<6}: {valence_distribution[val]} vertices")
585
+
586
+ report = [
587
+ f"File: {filepath}",
588
+ f"Vertices: {num_vertices} | Faces: {num_faces}",
589
+ f"Edges: {len(edge_dict)} | Genus: {genus}",
590
+ ]
591
+ if run_symmetry:
592
+ report.append(f"Symmetry Group: {symmetry_symbol} (order {symmetry_order})")
593
+ report.extend([
594
+ f"V-E-F: {num_vertices}-{len(edge_dict)}-{actual_num_faces}",
595
+ f"Compound Parts: {compound_parts}",
596
+ f"Coordinate Precision: {max_decimal_places} decimal places",
597
+ f"Wiener Index (Geom): {disp_wiener}",
598
+ "-" * 45,
599
+ "FACE COMPOSITION:",
600
+ *face_comp_report,
601
+ "-" * 45,
602
+ "VERTEX VALENCE DISTRIBUTION:",
603
+ *valence_report,
604
+ "-" * 45,
605
+ f"Duplicate Coordinates: {duplicate_coords_count} sets",
606
+ f"Faces w/ Repeat Indices: {len(faces_with_repeats)}",
607
+ f"Crossed Faces: {len(crossed_faces)}",
608
+ f"Concave Faces: {len(concave_faces)}",
609
+ f"Non-Planar Faces: {len(non_planar_faces)}",
610
+ f"Max Planarity Error: {max_planar_err:.8e}",
611
+ f"Zero-Area Faces: {len(zero_area_faces)}",
612
+ f"Hemihedral Faces: {len(hemihedral_faces)}",
613
+ f"Smallest Face Area: {disp_min_area}",
614
+ f"Shortest Edge Length: {disp_shortest_edge}",
615
+ f"Closest Vertex Distance: {disp_closest_vertex}",
616
+ f"Max Vertex Dist (Origin): {disp_max_origin_dist}",
617
+ f"Min Vertex Dist (Origin): {disp_min_origin_dist}",
618
+ "-" * 45,
619
+ f"Manifold Edges: {len(edge_dict) - len(boundary) - len(non_manifold)}",
620
+ f"Boundary Edges (1 face): {len(boundary)}",
621
+ f"Non-Manifold Edges (>2 faces): {len(non_manifold)}",
622
+ "-" * 45
623
+ ])
624
+
625
+ issues = []
626
+ if num_faces == 0: issues.append("Header contains zero faces")
627
+ elif processed_face_count == 0: issues.append("No valid face definitions found")
628
+ if duplicate_coords_count: issues.append("Duplicate vertex coordinates")
629
+ if faces_with_repeats: issues.append("Faces with repeated indices")
630
+ if non_planar_faces: issues.append("Non-planar faces (Quads+)")
631
+ if zero_area_faces: issues.append("Zero-area (degenerate) faces")
632
+ if hemihedral_faces: issues.append("Hemihedral faces")
633
+ if boundary: issues.append("Open boundaries (holes)")
634
+ if non_manifold: issues.append("Non-manifold geometry")
635
+
636
+ if not issues and processed_face_count > 0:
637
+ report.append("CONCLUSION: Perfectly closed manifold mesh.")
638
+ else:
639
+ report.append("ISSUES DETECTED:")
640
+ if not issues and processed_face_count == 0:
641
+ report.append(" - Malformed file: No faces processed.")
642
+ else:
643
+ for issue in issues: report.append(f" - {issue}")
644
+
645
+ if return_stats:
646
+ stats = {
647
+ "Filename": os.path.basename(filepath),
648
+ "Vertices": num_vertices,
649
+ "Faces": num_faces,
650
+ "Edges": len(edge_dict),
651
+ "Genus": genus,
652
+ "V-E-F": f"{num_vertices}-{len(edge_dict)}-{actual_num_faces}",
653
+ "Concentration": gonality, # Maintain name or leave empty
654
+ "Gonality": gonality,
655
+ "Valence": shared_valence,
656
+ "Symmetry Group": f"{symmetry_symbol} (order {symmetry_order})" if (run_symmetry and symmetry_symbol) else "",
657
+ "Compound Parts": compound_parts,
658
+ "Coordinate Precision": max_decimal_places,
659
+ "Wiener Index (Geom)": wiener_index,
660
+ "Duplicate Coords": duplicate_coords_count,
661
+ "Repeat Indices": len(faces_with_repeats),
662
+ "Crossed Faces": len(crossed_faces),
663
+ "Concave Faces": len(concave_faces),
664
+ "Non-Planar Faces": len(non_planar_faces),
665
+ "Max Planarity Error": max_planar_err,
666
+ "Zero-Area Faces": len(zero_area_faces),
667
+ "Hemihedral Faces": len(hemihedral_faces),
668
+ "Smallest Face Area": min_face_area,
669
+ "Shortest Edge Length": shortest_edge_len,
670
+ "Closest Vertex Distance": closest_vertex_dist,
671
+ "Max Vertex Dist (Origin)": max_origin_dist if origin_distances else float('nan'),
672
+ "Min Vertex Dist (Origin)": min_origin_dist if origin_distances else float('nan'),
673
+ "Manifold Edges": len(edge_dict) - len(boundary) - len(non_manifold),
674
+ "Boundary Edges": len(boundary),
675
+ "Non-Manifold Edges": len(non_manifold),
676
+ "Detail": "; ".join(issues) if issues else ("No faces processed." if processed_face_count == 0 else ""),
677
+ }
678
+
679
+ for v_count, count in face_type_counts.items():
680
+ stats[f"Faces ({v_count})"] = count
681
+ for val, count in valence_distribution.items():
682
+ stats[f"Valence ({val})"] = count
683
+
684
+ return "\n".join(report), stats
685
+
686
+ return "\n".join(report)
687
+
688
+
689
+ class OFFCheckerGUI:
690
+ # Version: 4.5
691
+
692
+ def __init__(self, root):
693
+ self.root = root
694
+ self.root.title("OFF Quality Checker")
695
+
696
+ # 1. Position on the left and set width to 560 to resolve the narrow text clipping
697
+ self.root.geometry("560x750+30+30")
698
+
699
+ # 2. Hide window during construction to block premature focus grabbing
700
+ self.root.withdraw()
701
+ self.set_icon()
702
+ self.root.update_idletasks()
703
+
704
+ # 3. Lower the stack priority and strip topmost triggers before displaying
705
+ self.root.lower()
706
+ self.root.wm_attributes("-topmost", False)
707
+
708
+ # 4. Initialize layout components while window is withdrawn
709
+ self.label = tk.Label(root, text="DRAG & DROP OFF FILE HERE",
710
+ font=("Arial", 10, "bold"), pady=15, fg="#555")
711
+ self.label.pack()
712
+
713
+ self.btn = tk.Button(root, text="Browse File Manually", command=self.browse_file, padx=20)
714
+ self.btn.pack(pady=5)
715
+
716
+ self.batch_var = tk.BooleanVar(value=False)
717
+ self.batch_chk = tk.Checkbutton(root, text="Batch Mode", variable=self.batch_var)
718
+ self.batch_chk.pack(pady=5)
719
+
720
+ self.sym_var = tk.BooleanVar(value=False)
721
+ self.sym_chk = tk.Checkbutton(root, text="Enable Symmetry Check", variable=self.sym_var)
722
+ self.sym_chk.pack(pady=5)
723
+
724
+ # Adjusted text width from 80 to 68 to prevent boundary overflows
725
+ self.text_area = scrolledtext.ScrolledText(root, width=68, height=1, font=("Consolas", 10))
726
+ self.text_area.pack(pady=10, padx=10, expand=True, fill=tk.BOTH)
727
+
728
+ windnd.hook_dropfiles(self.root, func=self.handle_drop)
729
+
730
+ # 5. Display the fully realized layout
731
+ self.root.deiconify()
732
+
733
+ if len(sys.argv) > 1:
734
+ self.process_file(sys.argv[1])
735
+
736
+ def set_icon(self):
737
+ icon_path = os.path.abspath("offchecker.ico")
738
+ if not os.path.exists(icon_path):
739
+ return
740
+
741
+ try:
742
+ # Standard Tkinter approach
743
+ self.root.iconbitmap(icon_path)
744
+
745
+ # Heavy-duty Win32 API override via ctypes
746
+ hwnd = self.root.winfo_id()
747
+ user32 = ctypes.windll.user32
748
+
749
+ # Load icon (LR_LOADFROMFILE = 0x10, IMAGE_ICON = 1)
750
+ hicon = user32.LoadImageW(0, icon_path, 1, 0, 0, 0x00000010)
751
+
752
+ if hicon:
753
+ # Set icon for current window (WM_SETICON = 0x80)
754
+ user32.SendMessageW(hwnd, 0x0080, 0, hicon) # ICON_SMALL
755
+ user32.SendMessageW(hwnd, 0x0080, 1, hicon) # ICON_BIG
756
+
757
+ # Set icon for the Window Class (more persistent)
758
+ # GCLP_HICON = -14, GCLP_HICONSM = -34
759
+ if ctypes.sizeof(ctypes.c_void_p) == 8: # 64-bit
760
+ user32.SetClassLongPtrW(hwnd, -14, hicon)
761
+ user32.SetClassLongPtrW(hwnd, -34, hicon)
762
+ else: # 32-bit
763
+ user32.SetClassLongW(hwnd, -14, hicon)
764
+ user32.SetClassLongW(hwnd, -34, hicon)
765
+ except Exception:
766
+ pass
767
+
768
+ def handle_drop(self, files):
769
+ if files:
770
+ filepath = files[0].decode('gbk')
771
+ self.process_file(filepath)
772
+
773
+ def browse_file(self):
774
+ file_path = filedialog.askopenfilename(filetypes=[("OFF files", "*.off"), ("All files", "*.*")])
775
+ if file_path:
776
+ self.process_file(file_path)
777
+
778
+ def process_file(self, filepath):
779
+ self.text_area.delete(1.0, tk.END)
780
+ sym_enabled = self.sym_var.get()
781
+
782
+ if self.batch_var.get():
783
+ folder = os.path.dirname(os.path.abspath(filepath))
784
+ off_files = [os.path.join(folder, f) for f in os.listdir(folder) if f.lower().endswith('.off')]
785
+ if not off_files:
786
+ self.text_area.insert(tk.END, "No OFF files found in the directory.")
787
+ return
788
+
789
+ csv_path = os.path.join(folder, 'offcheck.csv')
790
+
791
+ base_headers = [
792
+ "Filename", "Vertices", "Faces", "Edges", "Genus", "V-E-F", "Gonality", "Valence",
793
+ "Symmetry Group", "Compound Parts", "Coordinate Precision",
794
+ "Wiener Index (Geom)",
795
+ "Duplicate Coords", "Repeat Indices", "Crossed Faces", "Concave Faces", "Non-Planar Faces",
796
+ "Max Planarity Error", "Zero-Area Faces", "Hemihedral Faces", "Smallest Face Area",
797
+ "Shortest Edge Length", "Closest Vertex Distance",
798
+ "Max Vertex Dist (Origin)", "Min Vertex Dist (Origin)",
799
+ "Manifold Edges", "Boundary Edges", "Non-Manifold Edges", "Detail"
800
+ ]
801
+
802
+ results = []
803
+ dynamic_face_keys = set()
804
+ dynamic_valence_keys = set()
805
+ success_count = 0
806
+
807
+ for fpath in sorted(off_files):
808
+ res, stats = verify_off_logic(fpath, return_stats=True, run_symmetry=sym_enabled)
809
+ if stats is None:
810
+ stats = {h: "" for h in base_headers}
811
+ stats["Filename"] = os.path.basename(fpath)
812
+ stats["Detail"] = res
813
+ else:
814
+ for k in stats.keys():
815
+ if k.startswith("Faces ("):
816
+ dynamic_face_keys.add(k)
817
+ elif k.startswith("Valence ("):
818
+ dynamic_valence_keys.add(k)
819
+ results.append(stats)
820
+ success_count += 1
821
+
822
+ def extract_num(key_str):
823
+ m = re.search(r'\d+', key_str)
824
+ return int(m.group()) if m else 999999
825
+
826
+ sorted_face_headers = sorted(list(dynamic_face_keys), key=extract_num)
827
+ sorted_valence_headers = sorted(list(dynamic_valence_keys), key=extract_num)
828
+
829
+ headers = base_headers + sorted_face_headers + sorted_valence_headers
830
+
831
+ try:
832
+ with open(csv_path, 'w', newline='', encoding='utf-8') as csvfile:
833
+ writer = csv.DictWriter(csvfile, fieldnames=headers)
834
+ writer.writeheader()
835
+ for row in results:
836
+ row_data = {h: row.get(h, "") for h in headers}
837
+ writer.writerow(row_data)
838
+
839
+ summary = [
840
+ f"BATCH MODE COMPLETE",
841
+ f"Folder: {folder}",
842
+ f"Processed {success_count} .off files.",
843
+ f"Results written to: {csv_path}",
844
+ "-" * 45,
845
+ ]
846
+ for row in results:
847
+ summary.append(f"{row['Filename']:<30} | {row['Detail']}")
848
+ self.text_area.insert(tk.END, "\n".join(summary))
849
+ self.text_area.configure(height=min(len(summary) + 1, 30))
850
+ except Exception as e:
851
+ self.text_area.insert(tk.END, f"Error writing CSV file: {e}")
852
+ else:
853
+ result = verify_off_logic(filepath, run_symmetry=sym_enabled)
854
+ self.text_area.insert(tk.END, result)
855
+ line_count = result.count('\n') + 1
856
+ self.text_area.configure(height=line_count)
857
+
858
+ def main():
859
+ root = tk.Tk()
860
+ try:
861
+ app = OFFCheckerGUI(root)
862
+ except AttributeError as error:
863
+ raise ValueError(
864
+ "This tool requires a MS Windows. Did you try to run this on another OS? "
865
+ ) from error
866
+ root.mainloop()
867
+
868
+ if __name__ == "__main__":
869
+ main()