-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathglowing_polyhedron_sim.py
More file actions
2430 lines (2020 loc) · 99 KB
/
glowing_polyhedron_sim.py
File metadata and controls
2430 lines (2020 loc) · 99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Interactive simulator front-end for poly_solver solvers."""
from __future__ import annotations
import math
import random
import threading
import time
import tkinter as tk
from collections import Counter
from tkinter import ttk
from polyhedra import PolyhedronGenerators
from poly_solver import (
analyze_current_flow,
analyze_sneak_paths,
exhaustive_orientations_max_coverage_with_fixed_endpoints,
sampled_orientations_max_coverage_with_fixed_endpoints,
solve_fixed_orientation_min_endpoints_with_L,
is_solution_better,
solve_two_feedpoint,
enumerate_two_feedpoint_paths,
)
import random
def sampled_orientations_with_constraints_cancellable(V, undirected_edges, L, constraints, iters, cancel_event, seed=0):
"""
Cancellable version of sampled orientations solver that returns partial results.
Returns:
tuple: (best_solution, iterations_completed)
- best_solution: Best solution found so far (or None if none found)
- iterations_completed: Number of iterations actually completed
"""
if constraints is None:
constraints = []
random.seed(seed)
m = len(undirected_edges)
best = None
iterations_completed = 0
for i in range(iters):
# Check for cancellation every 10 iterations for responsiveness
if i % 10 == 0 and cancel_event.is_set():
break
mask = random.getrandbits(m)
dir_edges = [(u,v) if ((mask>>i)&1) else (v,u) for i,(u,v) in enumerate(undirected_edges)]
res = solve_fixed_orientation_min_endpoints_with_L(V, undirected_edges, dir_edges, L)
if res is None:
iterations_completed += 1
continue
# Apply all constraints
chosen_paths = res[5]
constraint_satisfied = True
for constraint_func in constraints:
if not constraint_func(chosen_paths, dir_edges, V):
constraint_satisfied = False
break
if not constraint_satisfied:
iterations_completed += 1
continue
if best is None:
best = res
else:
if is_solution_better(res, best):
best = res
iterations_completed += 1
return best, iterations_completed
def analyze_driving_schemes(chosen_paths, dir_edges, endpoints, vertices):
"""
Analyze driving schemes for each path to determine optimal anode/cathode configurations
that prevent unwanted LED activation.
Args:
chosen_paths: List of paths (each path is a list of vertices)
dir_edges: List of directed edges in the solution
endpoints: Set of endpoint vertices
vertices: List of all vertices in the graph
Returns:
List of dictionaries, one per path, containing driving scheme analysis
"""
analysis_results = []
# Create a mapping of edges to their direction
edge_directions = {}
for u, v in dir_edges:
edge_directions[(u, v)] = (u, v) # Forward direction
edge_directions[(v, u)] = (u, v) # Store original direction for reverse lookup
for path_idx, path in enumerate(chosen_paths):
if len(path) < 2:
continue
start_vertex = path[0]
end_vertex = path[-1]
# Create path edges
path_edges = []
for i in range(len(path) - 1):
u, v = path[i], path[i + 1]
# Find the correct direction for this edge
if (u, v) in edge_directions:
path_edges.append((u, v))
elif (v, u) in edge_directions:
path_edges.append((v, u))
else:
path_edges.append((u, v)) # Fallback
# Try to find a feasible driving scheme
scheme_result = find_driving_scheme(
path, path_edges, start_vertex, end_vertex,
endpoints, vertices, dir_edges
)
analysis_results.append({
'path': path,
'scheme': scheme_result
})
return analysis_results
def find_driving_scheme(target_path, target_edges, start_vertex, end_vertex, endpoints, vertices, all_dir_edges):
"""
Find a driving scheme for a specific path that drives only this path.
For single-path driving:
- Start vertex = Anode (A)
- End vertex = Cathode (C)
- All other endpoints = High impedance (Z)
Returns a dictionary with scheme feasibility and assignments.
"""
# For single-path driving, the scheme is simple:
# - Path start = A, path end = C, all other endpoints = Z
other_endpoints = [ep for ep in endpoints if ep not in (start_vertex, end_vertex)]
# Create the single-path assignment
full_assignment = {start_vertex: 'A', end_vertex: 'C'}
for ep in other_endpoints:
full_assignment[ep] = 'Z'
# Check this assignment scheme for conflicts
conflicts, blocked_paths = evaluate_scheme(
full_assignment, target_edges, all_dir_edges, vertices
)
return {
'feasible': len(conflicts) == 0,
'assignments': full_assignment,
'conflicts': conflicts,
'blocked_paths': blocked_paths,
'required_disconnections': other_endpoints
}
def evaluate_scheme(assignments, target_edges, all_dir_edges, vertices):
"""
Evaluate a driving scheme to find conflicts (unwanted LED activations).
Returns:
conflicts: List of edges that would be incorrectly activated
blocked_paths: List of paths that are correctly blocked
"""
conflicts = []
blocked_paths = []
# Check all directed edges to see which ones would be activated
for u, v in all_dir_edges:
u_assignment = assignments.get(u, 'Z')
v_assignment = assignments.get(v, 'Z')
# LED activates if u=Anode (A) and v=Cathode (C)
edge_activated = (u_assignment == 'A' and v_assignment == 'C')
edge_is_target = (u, v) in target_edges or (v, u) in target_edges
if edge_activated and not edge_is_target:
# This edge shouldn't be activated but would be
conflicts.append((u, v))
elif not edge_activated and edge_is_target:
# This is a target edge that won't be activated (might be okay if Z is involved)
pass
elif not edge_activated:
# This edge is successfully blocked
blocked_paths.append([u, v])
return conflicts, blocked_paths
def find_sneak_paths_avoided(target_path, z_endpoints, all_dir_edges):
"""
Find sneak paths that would be created if Z endpoints were connected as A or C.
Args:
target_path: The desired path being analyzed
z_endpoints: List of endpoints assigned to Z (unconnected)
all_dir_edges: All directed edges in the solution
Returns:
List of sneak path edges that are avoided by the Z assignments
"""
if not z_endpoints:
return []
sneak_paths = []
target_start = target_path[0] if target_path else None
target_end = target_path[-1] if target_path else None
# Look for edges that would create unwanted paths if Z endpoints were connected
for u, v in all_dir_edges:
# Check if this edge would create a sneak path
edge_involves_z = u in z_endpoints or v in z_endpoints
if edge_involves_z:
# This edge is blocked by having one end as Z
# Check if connecting it would create a shorter or unwanted path
if u in z_endpoints and v == target_end:
# u->target_end would be a sneak path if u were made anode
sneak_paths.append((u, v))
elif v in z_endpoints and u == target_start:
# target_start->v would be a sneak path if v were made cathode
sneak_paths.append((u, v))
elif u in z_endpoints or v in z_endpoints:
# Any other edge involving Z endpoints could be a potential sneak
sneak_paths.append((u, v))
# Remove duplicates and limit to most relevant ones
unique_sneaks = list(set(sneak_paths))
return unique_sneaks[:3] # Return up to 3 most relevant sneak paths
CANVAS_SIZE = 480
MARGIN = 50
BACKGROUND = "#f4f4f6"
ARROW_FOREGROUND_COLOR = "#ff4d4d"
ARROW_BACKGROUND_COLOR = "#b0b3c0"
EDGE_COLOR = "#1f1f24"
HIDDEN_EDGE_COLOR = "#8b8d99"
VERTEX_OUTLINE = "#2f2f35"
VERTEX_FILL_NEAR = "#ffffff"
VERTEX_FILL_FAR = "#d0d3de"
LABEL_COLOR = "#3399ff" # Light blue for better contrast
ENDPOINT_FILL = "#ffd166"
ARROW_COLOR = "#ff4d4d"
ISO_Y = math.radians(45)
ISO_X = math.radians(35.264)
VIEW_MODES = {
"Isometric": (ISO_Y, ISO_X),
"Axis-Aligned": (0.0, 0.0),
"Top-Tilt": (math.radians(20), math.radians(60)),
"Diagonal": (math.radians(30), math.radians(135)),
"Low-Angle": (math.radians(70), math.radians(25)),
"Graph": "graph", # Special case: 2D force-directed layout
"Schlegel": "schlegel", # Special case: Schlegel diagram (planar embedding)
}
POLYHEDRA = {
# Sorted by number of edges (ascending)
"Square": PolyhedronGenerators.undirected_square, # 4 edges
"Regular Tetrahedron": PolyhedronGenerators.undirected_tetrahedron, # 6 edges
"Triangular Prism": PolyhedronGenerators.undirected_triangular_prism, # 9 edges
"Cube": PolyhedronGenerators.undirected_cube, # 12 edges
"Octahedron": PolyhedronGenerators.undirected_octahedron, # 12 edges
"Pentagonal Prism": PolyhedronGenerators.undirected_pentagonal_prism, # 15 edges
"Square Antiprism": PolyhedronGenerators.undirected_square_antiprism, # 16 edges
"Star Octahedron": PolyhedronGenerators.undirected_star_octahedron, # 16 edges
"Truncated Tetrahedron": PolyhedronGenerators.undirected_truncated_tetrahedron, # 18 edges
"Stellated Tetrahedron": PolyhedronGenerators.undirected_stellated_tetrahedron, # 18 edges
"Hexagonal Prism": PolyhedronGenerators.undirected_hexagonal_prism, # 18 edges
"Hexagonal Bipyramid": PolyhedronGenerators.undirected_hexagonal_bipyramid, # 18 edges
"Pentagonal Antiprism": PolyhedronGenerators.undirected_pentagonal_antiprism, # 20 edges
"Elongated Octahedron": PolyhedronGenerators.undirected_elongated_octahedron, # 20 edges
"Hexagonal Antiprism": PolyhedronGenerators.undirected_hexagonal_antiprism, # 24 edges
"Triangular Orthobicupola": PolyhedronGenerators.undirected_triangular_orthobicupola, # 24 edges
"Cuboctahedron": PolyhedronGenerators.undirected_cuboctahedron, # 24 edges
"Octagonal Bipyramid": PolyhedronGenerators.undirected_octagonal_bipyramid, # 24 edges
"Rhombic Dodecahedron": PolyhedronGenerators.undirected_rhombic_dodecahedron, # 24 edges
"Stellated Triangular Prism": PolyhedronGenerators.undirected_stellated_triangular_prism, # 27 edges
"Dodecahedron": PolyhedronGenerators.undirected_dodecahedron, # 30 edges
"Icosahedron": PolyhedronGenerators.undirected_icosahedron, # 30 edges
"Elongated Hexagonal Bipyramid": PolyhedronGenerators.undirected_elongated_hexagonal_bipyramid, # 30 edges
"Stellated Cube": PolyhedronGenerators.undirected_stellated_cube, # 36 edges
"Stellated Octahedron": PolyhedronGenerators.undirected_stellated_octahedron, # 36 edges
# "Rhombicuboctahedron": PolyhedronGenerators.undirected_rhombicuboctahedron, # 56 edges
# "Snub Cube": PolyhedronGenerators.undirected_snub_cube, # 60 edges
# "Rhombicosidodecahedron": PolyhedronGenerators.undirected_rhombicosidodecahedron, # 78 edges
}
def compute_graph_layout(vertices, edges, iterations=100):
"""
Compute a 2D force-directed layout for the graph.
Uses Fruchterman-Reingold-style forces.
"""
import random
random.seed(42) # Deterministic layout
n = len(vertices)
if n == 0:
return []
# Initialize positions randomly
pos = [(random.uniform(0.1, 0.9), random.uniform(0.1, 0.9)) for _ in range(n)]
# Build adjacency for quick lookup
adj = [set() for _ in range(n)]
for u, v in edges:
adj[u].add(v)
adj[v].add(u)
# Ideal spring length
k = 1.0 / math.sqrt(n)
temperature = 0.1
for iteration in range(iterations):
# Calculate repulsive forces (all pairs)
disp = [(0.0, 0.0) for _ in range(n)]
for i in range(n):
for j in range(i + 1, n):
dx = pos[i][0] - pos[j][0]
dy = pos[i][1] - pos[j][1]
dist = math.sqrt(dx * dx + dy * dy) + 0.001
# Repulsive force
force = (k * k) / dist
fx, fy = (dx / dist) * force, (dy / dist) * force
disp[i] = (disp[i][0] + fx, disp[i][1] + fy)
disp[j] = (disp[j][0] - fx, disp[j][1] - fy)
# Calculate attractive forces (edges only)
for u, v in edges:
dx = pos[u][0] - pos[v][0]
dy = pos[u][1] - pos[v][1]
dist = math.sqrt(dx * dx + dy * dy) + 0.001
# Attractive force
force = (dist * dist) / k
fx, fy = (dx / dist) * force, (dy / dist) * force
disp[u] = (disp[u][0] - fx, disp[u][1] - fy)
disp[v] = (disp[v][0] + fx, disp[v][1] + fy)
# Apply displacements with temperature limiting
for i in range(n):
dx, dy = disp[i]
dist = math.sqrt(dx * dx + dy * dy) + 0.001
scale = min(dist, temperature) / dist
new_x = pos[i][0] + dx * scale
new_y = pos[i][1] + dy * scale
# Keep within bounds
new_x = max(0.05, min(0.95, new_x))
new_y = max(0.05, min(0.95, new_y))
pos[i] = (new_x, new_y)
# Cool down
temperature *= 0.95
# Normalize positions to fill the canvas
xs = [p[0] for p in pos]
ys = [p[1] for p in pos]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
# Avoid division by zero for single-vertex or collinear layouts
range_x = max(max_x - min_x, 0.001)
range_y = max(max_y - min_y, 0.001)
# Use uniform scaling to preserve aspect ratio
scale = min((CANVAS_SIZE - 2 * MARGIN) / range_x, (CANVAS_SIZE - 2 * MARGIN) / range_y)
# Center the layout
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
canvas_center = CANVAS_SIZE / 2
result = []
for x, y in pos:
px = canvas_center + (x - center_x) * scale
py = canvas_center + (y - center_y) * scale
result.append((px, py, 0)) # z=0 for 2D layout
return result
def find_outer_face(vertices, edges, coords):
"""
Find a good outer face for the Schlegel diagram.
Prefers larger faces for better symmetry, then uses z-coordinate as tiebreaker.
"""
# Build adjacency list
n = len(vertices)
adj = [set() for _ in range(n)]
for u, v in edges:
adj[u].add(v)
adj[v].add(u)
# Find all faces using cycle detection (triangles through octagons)
faces = []
# Find triangular faces
for u in range(n):
for v in adj[u]:
if v > u:
for w in adj[u] & adj[v]:
if w > v:
faces.append([u, v, w])
# Find quadrilateral faces (4-cycles that aren't just two triangles)
for u in range(n):
for v in adj[u]:
if v > u:
for w in adj[v]:
if w != u and w > u:
for x in adj[w] & adj[u]:
if x > v and x != v and x not in adj[v]:
faces.append([u, v, w, x])
# Try to find larger cycles by looking for "equatorial" cycles around apex vertices
# A vertex with many neighbors might be an apex; the cycle of its neighbors could be the outer face
for apex in range(n):
neighbors = list(adj[apex])
if len(neighbors) >= 4:
# Try to find a cycle through these neighbors
cycle = find_cycle_through_vertices(neighbors, adj)
if cycle and len(cycle) >= 4:
# Verify it's a valid face (not containing the apex)
if apex not in cycle:
faces.append(cycle)
# Try to find planar cycles (vertices sharing same y-coordinate)
# This helps with structures like star octahedron where equatorial vertices form a cycle
y_groups = {}
for v in range(n):
y = round(coords[v][1], 4) # Group by y-coordinate
if y not in y_groups:
y_groups[y] = []
y_groups[y].append(v)
for y, group in y_groups.items():
if len(group) >= 4:
cycle = find_cycle_through_vertices(group, adj)
if cycle and len(cycle) >= 4:
faces.append(cycle)
if not faces:
# Fallback: use vertices with highest degree as outer face
degrees = [(len(adj[v]), v) for v in range(n)]
degrees.sort(reverse=True)
return [d[1] for d in degrees[:min(4, n)]]
# Prefer larger faces for better symmetry in the Schlegel diagram
# Among same-size faces, use highest average z-coordinate
max_size = max(len(f) for f in faces)
large_faces = [f for f in faces if len(f) == max_size]
best_face = large_faces[0]
best_z = sum(coords[v][2] for v in large_faces[0]) / len(large_faces[0])
for face in large_faces[1:]:
avg_z = sum(coords[v][2] for v in face) / len(face)
if avg_z > best_z:
best_z = avg_z
best_face = face
return best_face
def find_cycle_through_vertices(vertex_set, adj):
"""
Try to find a cycle that visits all vertices in vertex_set exactly once.
Uses DFS to find a Hamiltonian path through the induced subgraph.
"""
if len(vertex_set) < 3:
return None
vertex_list = list(vertex_set)
vertex_set = set(vertex_set)
# Build induced subgraph adjacency
induced_adj = {v: adj[v] & vertex_set for v in vertex_set}
# Check if all vertices have degree >= 2 in induced subgraph (necessary for cycle)
for v in vertex_set:
if len(induced_adj[v]) < 2:
return None
# DFS to find Hamiltonian cycle
def dfs(current, visited, path):
if len(path) == len(vertex_set):
# Check if we can close the cycle
if path[0] in induced_adj[current]:
return path
return None
for neighbor in induced_adj[current]:
if neighbor not in visited:
visited.add(neighbor)
path.append(neighbor)
result = dfs(neighbor, visited, path)
if result:
return result
path.pop()
visited.remove(neighbor)
return None
# Try starting from each vertex
for start in vertex_list:
result = dfs(start, {start}, [start])
if result:
return result
return None
def compute_schlegel_layout(vertices, edges, coords):
"""
Compute a Schlegel diagram layout using Tutte embedding.
The outer face vertices are placed on a circle, and interior vertices
are placed at the barycenter of their neighbors.
"""
n = len(vertices)
if n == 0:
return []
# Build adjacency list
adj = [set() for _ in range(n)]
for u, v in edges:
adj[u].add(v)
adj[v].add(u)
# Find the outer face
outer_face = find_outer_face(vertices, edges, coords)
outer_set = set(outer_face)
interior = [v for v in vertices if v not in outer_set]
# Place outer face vertices on a circle
pos = [None] * n
num_outer = len(outer_face)
for i, v in enumerate(outer_face):
angle = 2 * math.pi * i / num_outer - math.pi / 2 # Start from top
pos[v] = (0.5 + 0.45 * math.cos(angle), 0.5 + 0.45 * math.sin(angle))
# Initialize interior vertices at center
for v in interior:
pos[v] = (0.5, 0.5)
# Tutte embedding: iteratively move interior vertices to barycenter of neighbors
for _ in range(100):
max_move = 0
for v in interior:
neighbors = adj[v]
if neighbors:
avg_x = sum(pos[u][0] for u in neighbors) / len(neighbors)
avg_y = sum(pos[u][1] for u in neighbors) / len(neighbors)
dx = avg_x - pos[v][0]
dy = avg_y - pos[v][1]
max_move = max(max_move, abs(dx), abs(dy))
pos[v] = (avg_x, avg_y)
if max_move < 0.0001:
break
# Separate overlapping interior vertices (e.g., two apexes with same neighbors)
# Use diagonal offset based on the 3D coordinate with largest difference
overlap_threshold = 0.001
for i, v1 in enumerate(interior):
for v2 in interior[i+1:]:
dx = pos[v1][0] - pos[v2][0]
dy = pos[v1][1] - pos[v2][1]
dist = math.sqrt(dx*dx + dy*dy)
if dist < overlap_threshold:
# Find the 3D coordinate axis with the largest difference
c1 = coords[v1]
c2 = coords[v2]
diffs = [(abs(c1[j] - c2[j]), c1[j] - c2[j], j) for j in range(len(c1))]
max_diff = max(diffs, key=lambda x: x[0])
sign = 1 if max_diff[1] > 0 else -1
offset = 0.15 # Diagonal separation
# v1 with higher coord value goes upper-left, v2 goes lower-right
pos[v1] = (pos[v1][0] - sign * offset, pos[v1][1] - sign * offset)
pos[v2] = (pos[v2][0] + sign * offset, pos[v2][1] + sign * offset)
# Scale to canvas coordinates
xs = [p[0] for p in pos]
ys = [p[1] for p in pos]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
range_x = max(max_x - min_x, 0.001)
range_y = max(max_y - min_y, 0.001)
scale = min((CANVAS_SIZE - 2 * MARGIN) / range_x, (CANVAS_SIZE - 2 * MARGIN) / range_y)
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
canvas_center = CANVAS_SIZE / 2
result = []
for x, y in pos:
px = canvas_center + (x - center_x) * scale
py = canvas_center + (y - center_y) * scale
result.append((px, py, 0))
return result
def rotate_point(point, yaw, pitch):
x, y, z = point
x1 = x * math.cos(yaw) + z * math.sin(yaw)
z1 = -x * math.sin(yaw) + z * math.cos(yaw)
y2 = y * math.cos(pitch) - z1 * math.sin(pitch)
z2 = y * math.sin(pitch) + z1 * math.cos(pitch)
return x1, y2, z2
def project_points(coords, yaw, pitch):
rotated = [rotate_point(p, yaw, pitch) for p in coords]
xs = [p[0] for p in rotated]
ys = [p[1] for p in rotated]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
span_x = max(max_x - min_x, 1e-6)
span_y = max(max_y - min_y, 1e-6)
scale = min((CANVAS_SIZE - 2 * MARGIN) / span_x, (CANVAS_SIZE - 2 * MARGIN) / span_y)
projected = []
for x, y, z in rotated:
px = (x - min_x) * scale + MARGIN
py = (y - min_y) * scale + MARGIN
projected.append((px, CANVAS_SIZE - py, z))
return projected
def depth_to_color(z, z_min, z_max):
if z_max - z_min < 1e-6:
return VERTEX_FILL_NEAR
t = (z - z_min) / (z_max - z_min)
far_rgb = tuple(int(VERTEX_FILL_FAR[i : i + 2], 16) for i in (1, 3, 5))
near_rgb = tuple(int(VERTEX_FILL_NEAR[i : i + 2], 16) for i in (1, 3, 5))
blended = tuple(int(f + (n - f) * t) for f, n in zip(far_rgb, near_rgb))
return "#%02x%02x%02x" % blended
def current_to_color(current, max_current):
"""Convert current flow to color from black (0) to red (max)."""
if max_current <= 0:
return "#000000" # Black for no current
# Normalize current to 0-1 range
intensity = min(current / max_current, 1.0)
# Interpolate from black (0,0,0) to red (255,0,0)
red_component = int(255 * intensity)
return f"#{red_component:02x}0000"
def draw_arrowhead(canvas, x_start, y_start, x_end, y_end, color=ARROW_FOREGROUND_COLOR, size=14):
angle = math.atan2(y_end - y_start, x_end - x_start)
mx = (x_start + x_end) / 2
my = (y_start + y_end) / 2
spread = math.radians(25)
points = [
mx, my,
mx - size * math.cos(angle - spread), my - size * math.sin(angle - spread),
mx - size * math.cos(angle + spread), my - size * math.sin(angle + spread),
]
canvas.create_polygon(points, fill=color, outline=color)
def draw_polyhedron(canvas, builder, active_view, highlight=None, current_data=None, show_current_labels=False, shade_by_current=True):
canvas.delete("all")
canvas.create_rectangle(0, 0, CANVAS_SIZE, CANVAS_SIZE, fill=BACKGROUND, outline=BACKGROUND)
vertices, edges, coords = builder(return_coords=True)
# Check if this is a special 2D view or 3D projection
if active_view == "graph":
# Graph view: use force-directed 2D layout
projected = compute_graph_layout(vertices, edges)
is_2d_view = True
elif active_view == "schlegel":
# Schlegel diagram: planar embedding with outer face on boundary
projected = compute_schlegel_layout(vertices, edges, coords)
is_2d_view = True
else:
projected = project_points(coords, *active_view)
is_2d_view = False
z_vals = [p[2] for p in projected]
z_min, z_max = min(z_vals), max(z_vals)
depth_threshold = sum(z_vals) / len(z_vals)
highlight = highlight or {}
endpoint_indices = set(highlight.get('endpoints', []))
oriented_edges = highlight.get('edges', [])
all_directed_edges = highlight.get('all_directed_edges', [])
# Create orientation map from solution edges (for highlighting)
solution_orientation_map = {tuple(sorted((u, v))): (u, v) for u, v in oriented_edges}
# Create complete orientation map from all directed edges (for arrows)
complete_orientation_map = {tuple(sorted((u, v))): (u, v) for u, v in all_directed_edges}
def edge_depth(edge):
u, v = edge
return (projected[u][2] + projected[v][2]) / 2
# Calculate maximum current for color scaling
max_current = 0
if current_data:
max_current = max(data['current'] for data in current_data.values()) if current_data else 0
sorted_edges = sorted(edges, key=edge_depth)
# In 2D views, draw all edges in a single pass (no depth-based hiding)
if is_2d_view:
for u, v in sorted_edges:
x1, y1, _ = projected[u]
x2, y2, _ = projected[v]
# Determine edge color based on current flow
edge_key = (u, v)
has_current = (current_data and edge_key in current_data) or (current_data and (v, u) in current_data)
if has_current:
if shade_by_current:
# Gradient shading based on current magnitude
current = current_data.get(edge_key, current_data.get((v, u), {})).get('current', 0)
edge_color = current_to_color(current, max_current)
else:
# Simple red for lit edges
edge_color = "#ff4d4d"
else:
edge_color = EDGE_COLOR
canvas.create_line(x1, y1, x2, y2, fill=edge_color, width=3, capstyle=tk.ROUND)
else:
# 3D view: draw hidden edges first (dashed), then visible edges
for u, v in sorted_edges:
x1, y1, z1 = projected[u]
x2, y2, z2 = projected[v]
depth = (z1 + z2) / 2
# Determine edge color based on current flow
edge_key = (u, v)
has_current = (current_data and edge_key in current_data) or (current_data and (v, u) in current_data)
if has_current:
if shade_by_current:
# Gradient shading based on current magnitude
current = current_data.get(edge_key, current_data.get((v, u), {})).get('current', 0)
edge_color = current_to_color(current, max_current)
else:
# Simple red for lit edges
edge_color = "#ff4d4d"
else:
# No current data or edge not in solution
edge_color = HIDDEN_EDGE_COLOR if depth > depth_threshold else EDGE_COLOR
if depth > depth_threshold:
canvas.create_line(x1, y1, x2, y2, fill=edge_color, dash=(6, 4), width=2)
for u, v in sorted_edges:
x1, y1, z1 = projected[u]
x2, y2, z2 = projected[v]
depth = (z1 + z2) / 2
# Determine edge color based on current flow
edge_key = (u, v)
has_current = (current_data and edge_key in current_data) or (current_data and (v, u) in current_data)
if has_current:
if shade_by_current:
# Gradient shading based on current magnitude
current = current_data.get(edge_key, current_data.get((v, u), {})).get('current', 0)
edge_color = current_to_color(current, max_current)
else:
# Simple red for lit edges
edge_color = "#ff4d4d"
else:
# No current data or edge not in solution
edge_color = HIDDEN_EDGE_COLOR if depth > depth_threshold else EDGE_COLOR
if depth <= depth_threshold:
canvas.create_line(x1, y1, x2, y2, fill=edge_color, width=3, capstyle=tk.ROUND)
# Draw arrows for all edges that have an orientation
for u, v in edges:
edge_key = (u, v)
has_current = (current_data and edge_key in current_data) or (current_data and (v, u) in current_data)
key = tuple(sorted((u, v)))
# Use complete orientation map to show arrows on all edges
orient = complete_orientation_map.get(key)
if orient:
x_start, y_start = projected[orient[0]][0], projected[orient[0]][1]
x_end, y_end = projected[orient[1]][0], projected[orient[1]][1]
# In 2D views, use consistent arrow size; in 3D view, vary by depth
if is_2d_view:
arrow_size = 16
else:
depth = (projected[u][2] + projected[v][2]) / 2
arrow_size = 16 if depth <= depth_threshold else 14
# Different arrow colors: red for current-carrying, gray for non-current
if has_current:
arrow_color = ARROW_FOREGROUND_COLOR # Red for current-carrying edges
else:
arrow_color = "#888888" # Gray for non-current edges
draw_arrowhead(canvas, x_start, y_start, x_end, y_end, color=arrow_color, size=arrow_size)
radius = 6
for idx, (x, y, z) in enumerate(projected):
# In 2D views, use consistent vertex color; in 3D view, vary by depth
if is_2d_view:
fill = ENDPOINT_FILL if idx in endpoint_indices else VERTEX_FILL_NEAR
else:
fill = ENDPOINT_FILL if idx in endpoint_indices else depth_to_color(z, z_min, z_max)
canvas.create_oval(
x - radius,
y - radius,
x + radius,
y + radius,
outline=VERTEX_OUTLINE,
width=2,
fill=fill,
)
canvas.create_text(x, y - radius - 10, text=str(idx), fill=LABEL_COLOR, font=("Helvetica", 13, "bold"))
# Draw current labels on edges if enabled and current data is available
if show_current_labels and current_data and max_current > 0:
for u, v in edges:
edge_key = (u, v)
reverse_key = (v, u)
# Get current value for this edge
current = None
if edge_key in current_data:
current = current_data[edge_key]['current']
elif reverse_key in current_data:
current = current_data[reverse_key]['current']
if current is not None:
# Calculate midpoint of edge
x1, y1, z1 = projected[u]
x2, y2, z2 = projected[v]
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
# Calculate relative current (normalized to max)
relative_current = current / max_current
# Format the label text without trailing zeros
label_text = f"{relative_current:.2f}".rstrip('0').rstrip('.')
# Offset the label slightly perpendicular to the edge to avoid overlap
dx = x2 - x1
dy = y2 - y1
length = (dx**2 + dy**2)**0.5
if length > 0:
# Perpendicular offset (normalized)
perp_x = -dy / length * 12
perp_y = dx / length * 12
mid_x += perp_x
mid_y += perp_y
# Draw label (non-bold to distinguish from vertex labels)
canvas.create_text(
mid_x, mid_y,
text=label_text,
fill="#000000",
font=("Helvetica", 9)
)
def build_constraint_suffix(dc_only: bool, sneak_free: bool, equal_current: bool, alternating_only: bool, bipolar_only: bool) -> str:
"""Build constraint suffix string for result formatting."""
constraints = []
if dc_only:
constraints.append("DC only")
if sneak_free:
constraints.append("sneak free")
if equal_current:
constraints.append("equal current")
if alternating_only:
constraints.append("alternating only")
if bipolar_only:
constraints.append("bipolar only")
return f" ({', '.join(constraints)})" if constraints else ""
def format_result_header(name: str, L, res, constraint_suffix: str, fixed_endpoints: int = 0, path_mode: str = "fixed") -> list:
"""Format the header section of the result."""
if res is None:
if fixed_endpoints > 0:
return [f"=== {name} | L={L}{constraint_suffix} ===", f"No solution found with exactly {fixed_endpoints} endpoints.", ""]
else:
return [f"=== {name} | L={L}{constraint_suffix} ===", "No equal-length-L exact cover found.", ""]
n_end, n_paths, endpoints, counts, dir_edges, chosen_paths = res
if fixed_endpoints > 0:
# Calculate edge coverage for fixed endpoints mode
covered_edges = set()
for path in chosen_paths:
for i in range(len(path) - 1):
covered_edges.add((path[i], path[i + 1]))
return [
f"=== {name} | L={L}: maximize coverage with {fixed_endpoints} endpoints{constraint_suffix} ===",
f"Fixed endpoints: {n_end} | Paths: {n_paths} | Edge coverage: {len(covered_edges)}",
f"Endpoint set: {endpoints}",
f"Endpoint usage counts: {dict(sorted(counts.items()))}",
]
else:
# Calculate edge coverage for all non-fixed modes
covered_edges = set()
for path in chosen_paths:
for i in range(len(path) - 1):
covered_edges.add((path[i], path[i + 1]))
if path_mode == "variable":
return [
f"=== {name} | Variable Path: maximize coverage, minimize endpoints{constraint_suffix} ===",
f"Best path length: {L} | Endpoints: {n_end} | Paths: {n_paths} | Edge coverage: {len(covered_edges)}",
f"Endpoint set: {endpoints}",
f"Endpoint usage counts: {dict(sorted(counts.items()))}",
]
elif path_mode == "mixed":
return [
f"=== {name} | Mixed Path: maximize coverage, minimize endpoints{constraint_suffix} ===",
f"Path lengths: {L} | Endpoints: {n_end} | Paths: {n_paths} | Edge coverage: {len(covered_edges)}",
f"Endpoint set: {endpoints}",
f"Endpoint usage counts: {dict(sorted(counts.items()))}",
]
else: # fixed mode
return [
f"=== {name} | L={L}: minimize distinct endpoints{constraint_suffix} ===",
f"Distinct endpoints: {n_end} | Paths: {n_paths}",
f"Endpoint set: {endpoints}",
f"Endpoint usage counts: {dict(sorted(counts.items()))}",
]
def format_path_details(chosen_paths, dir_edges, total_edges: int) -> list:
"""Format path listing and coverage details."""
lines = []
used = 0
for idx, path in enumerate(chosen_paths, 1):
edges = list(zip(path, path[1:]))
used += len(path) - 1
lines.append(f" Path {idx}: vertices {path} | len={len(path) - 1} | edges={edges}")
lines.append(f"Total edges covered: {used}")
# Add coverage ratio if below complete coverage
if total_edges > 0 and used < total_edges:
coverage_ratio = used / total_edges
lines.append(f"Coverage ratio: {coverage_ratio:.3f} ({used}/{total_edges})")
lines.append(f"Orientation (directed edges): {sorted(dir_edges)}")
return lines
def format_branching_analysis(chosen_paths, dir_edges) -> list:
"""Format branching analysis section."""
lines = ["", "=== Branching Analysis ==="]
flow_data = analyze_current_flow(chosen_paths, dir_edges)
# Test for equal current coverage
currents = [data['current'] for data in flow_data.values()]
equal_current = len(set(f"{c:.6f}" for c in currents)) == 1 if currents else False
uniform_current = currents[0] if equal_current and currents else None
if equal_current:
lines.append(f"Equal current coverage: YES (all edges carry {uniform_current:.3f} current)")
else:
min_current = min(currents) if currents else 0
max_current = max(currents) if currents else 0
lines.append(f"Equal current coverage: NO (range {min_current:.3f} to {max_current:.3f})")
lines.append("")
# Sort edges by their total current (descending) for better readability
sorted_edges = sorted(flow_data.items(), key=lambda x: x[1]['current'], reverse=True)
for edge, data in sorted_edges:
current = data['current']
branches = data['branches']