offviewer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- offviewer/.offcheck.py.swp +0 -0
- offviewer/__init__.py +5 -0
- offviewer/offcheck.py +869 -0
- offviewer/offviewer.py +1908 -0
- offviewer-0.1.0.dist-info/METADATA +49 -0
- offviewer-0.1.0.dist-info/RECORD +8 -0
- offviewer-0.1.0.dist-info/WHEEL +4 -0
- offviewer-0.1.0.dist-info/entry_points.txt +4 -0
offviewer/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()
|