bin2path 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.
- bin2path/__init__.py +48 -0
- bin2path/animate.py +237 -0
- bin2path/batch.py +38 -0
- bin2path/compare.py +139 -0
- bin2path/decode.py +33 -0
- bin2path/encode.py +151 -0
- bin2path/features.py +155 -0
- bin2path/orient.py +107 -0
- bin2path/path.py +70 -0
- bin2path/serialize.py +97 -0
- bin2path/validate.py +101 -0
- bin2path/visualize.py +380 -0
- bin2path-0.1.0.dist-info/METADATA +234 -0
- bin2path-0.1.0.dist-info/RECORD +17 -0
- bin2path-0.1.0.dist-info/WHEEL +5 -0
- bin2path-0.1.0.dist-info/licenses/LICENSE +21 -0
- bin2path-0.1.0.dist-info/top_level.txt +1 -0
bin2path/features.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Extract features from a path for clustering."""
|
|
2
|
+
|
|
3
|
+
from bin2path.path import Path3D
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def features(path: Path3D) -> dict:
|
|
8
|
+
"""
|
|
9
|
+
Extract features from a 3D path for clustering.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
path: A Path3D object.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Dictionary with feature values.
|
|
16
|
+
"""
|
|
17
|
+
vertices = np.array(path.vertices)
|
|
18
|
+
edges = path.edges
|
|
19
|
+
|
|
20
|
+
# Basic metrics
|
|
21
|
+
num_vertices = len(vertices)
|
|
22
|
+
num_edges = len(edges)
|
|
23
|
+
|
|
24
|
+
# Path length (number of steps)
|
|
25
|
+
path_length = num_edges
|
|
26
|
+
|
|
27
|
+
# Calculate turn count from edges
|
|
28
|
+
turns = _count_turns(path)
|
|
29
|
+
|
|
30
|
+
# Center of mass
|
|
31
|
+
center = vertices.mean(axis=0)
|
|
32
|
+
|
|
33
|
+
# Bounding box
|
|
34
|
+
min_coords = vertices.min(axis=0)
|
|
35
|
+
max_coords = vertices.max(axis=0)
|
|
36
|
+
bbox_size = max_coords - min_coords
|
|
37
|
+
|
|
38
|
+
# Start and end points
|
|
39
|
+
start = path.vertices[0]
|
|
40
|
+
end = path.vertices[-1]
|
|
41
|
+
displacement = (
|
|
42
|
+
end[0] - start[0],
|
|
43
|
+
end[1] - start[1],
|
|
44
|
+
end[2] - start[2],
|
|
45
|
+
)
|
|
46
|
+
direct_distance = np.linalg.norm(displacement)
|
|
47
|
+
|
|
48
|
+
# Direction histogram
|
|
49
|
+
dir_histogram = _direction_histogram(path)
|
|
50
|
+
|
|
51
|
+
# Self-intersections (simplified check)
|
|
52
|
+
self_intersections = _count_self_intersections(path)
|
|
53
|
+
|
|
54
|
+
# Straight segment lengths histogram
|
|
55
|
+
straight_segments = _straight_segment_lengths(path)
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"path_length": path_length,
|
|
59
|
+
"turns": turns,
|
|
60
|
+
"vertices_count": num_vertices,
|
|
61
|
+
"center_x": float(center[0]),
|
|
62
|
+
"center_y": float(center[1]),
|
|
63
|
+
"center_z": float(center[2]),
|
|
64
|
+
"bbox_x": float(bbox_size[0]),
|
|
65
|
+
"bbox_y": float(bbox_size[1]),
|
|
66
|
+
"bbox_z": float(bbox_size[2]),
|
|
67
|
+
"displacement_x": displacement[0],
|
|
68
|
+
"displacement_y": displacement[1],
|
|
69
|
+
"displacement_z": displacement[2],
|
|
70
|
+
"direct_distance": direct_distance,
|
|
71
|
+
"straightness": direct_distance / num_edges if num_edges > 0 else 0,
|
|
72
|
+
"direction_histogram": dir_histogram,
|
|
73
|
+
"self_intersections": self_intersections,
|
|
74
|
+
"straight_segments": straight_segments,
|
|
75
|
+
"original_number": path.metadata.original_number,
|
|
76
|
+
"bits_length": path.metadata.bits_length,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _count_turns(path: Path3D) -> int:
|
|
81
|
+
"""Count the number of direction changes (bits = 0)."""
|
|
82
|
+
# Turns = total bits - number of steps (edges) = bits_length - num_edges
|
|
83
|
+
# Это количество битов которые были 0
|
|
84
|
+
return path.metadata.bits_length - len(path.edges)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _direction_histogram(path: Path3D) -> dict:
|
|
88
|
+
"""Count steps in each of 6 directions."""
|
|
89
|
+
histogram = {"+X": 0, "-X": 0, "+Y": 0, "-Y": 0, "+Z": 0, "-Z": 0}
|
|
90
|
+
|
|
91
|
+
for from_idx, to_idx in path.edges:
|
|
92
|
+
from_v = path.vertices[from_idx]
|
|
93
|
+
to_v = path.vertices[to_idx]
|
|
94
|
+
dx = to_v[0] - from_v[0]
|
|
95
|
+
dy = to_v[1] - from_v[1]
|
|
96
|
+
dz = to_v[2] - from_v[2]
|
|
97
|
+
|
|
98
|
+
if dx == 1:
|
|
99
|
+
histogram["+X"] += 1
|
|
100
|
+
elif dx == -1:
|
|
101
|
+
histogram["-X"] += 1
|
|
102
|
+
elif dy == 1:
|
|
103
|
+
histogram["+Y"] += 1
|
|
104
|
+
elif dy == -1:
|
|
105
|
+
histogram["-Y"] += 1
|
|
106
|
+
elif dz == 1:
|
|
107
|
+
histogram["+Z"] += 1
|
|
108
|
+
elif dz == -1:
|
|
109
|
+
histogram["-Z"] += 1
|
|
110
|
+
|
|
111
|
+
return histogram
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _count_self_intersections(path: Path3D) -> int:
|
|
115
|
+
"""Count vertex revisits (simple intersection check)."""
|
|
116
|
+
seen = {}
|
|
117
|
+
intersections = 0
|
|
118
|
+
|
|
119
|
+
for i, v in enumerate(path.vertices):
|
|
120
|
+
if v in seen:
|
|
121
|
+
intersections += 1
|
|
122
|
+
else:
|
|
123
|
+
seen[v] = i
|
|
124
|
+
|
|
125
|
+
return intersections
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _straight_segment_lengths(path: Path3D) -> dict:
|
|
129
|
+
"""Histogram of straight segment lengths along any single axis."""
|
|
130
|
+
lengths: list[int] = []
|
|
131
|
+
current_length = 0
|
|
132
|
+
current_dir: tuple[int, int, int] | None = None
|
|
133
|
+
|
|
134
|
+
for from_idx, to_idx in path.edges:
|
|
135
|
+
from_v = path.vertices[from_idx]
|
|
136
|
+
to_v = path.vertices[to_idx]
|
|
137
|
+
edge_dir = (to_v[0] - from_v[0], to_v[1] - from_v[1], to_v[2] - from_v[2])
|
|
138
|
+
|
|
139
|
+
if current_dir is None or edge_dir == current_dir:
|
|
140
|
+
current_length += 1
|
|
141
|
+
current_dir = edge_dir
|
|
142
|
+
else:
|
|
143
|
+
if current_length > 0:
|
|
144
|
+
lengths.append(current_length)
|
|
145
|
+
current_length = 1
|
|
146
|
+
current_dir = edge_dir
|
|
147
|
+
|
|
148
|
+
if current_length > 0:
|
|
149
|
+
lengths.append(current_length)
|
|
150
|
+
|
|
151
|
+
hist: dict[int, int] = {}
|
|
152
|
+
for l in lengths:
|
|
153
|
+
hist[l] = hist.get(l, 0) + 1
|
|
154
|
+
|
|
155
|
+
return hist
|
bin2path/orient.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Orientation (local frame) utilities for bin2path.
|
|
2
|
+
|
|
3
|
+
We interpret symbolic steps L/R/U/D as *relative* moves based on a current
|
|
4
|
+
orientation defined by two orthonormal axis-aligned vectors:
|
|
5
|
+
- forward: where "R" moves (go forward)
|
|
6
|
+
- up: local up direction (used as yaw axis)
|
|
7
|
+
|
|
8
|
+
Right vector is computed as cross(forward, up) (right-hand rule).
|
|
9
|
+
|
|
10
|
+
Step semantics (each step includes the move):
|
|
11
|
+
- R: move forward
|
|
12
|
+
- L: yaw left (rotate forward 90° around up), then move forward
|
|
13
|
+
- U: pitch up (rotate forward 90° around right), then move forward
|
|
14
|
+
- D: pitch down(rotate forward 90° around right), then move forward
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Dict, Iterable, List, Tuple
|
|
20
|
+
|
|
21
|
+
Vec3 = Tuple[int, int, int]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _cross(a: Vec3, b: Vec3) -> Vec3:
|
|
25
|
+
ax, ay, az = a
|
|
26
|
+
bx, by, bz = b
|
|
27
|
+
return (ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _neg(v: Vec3) -> Vec3:
|
|
31
|
+
return (-v[0], -v[1], -v[2])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def apply_step(sym: str, forward: Vec3, up: Vec3) -> tuple[Vec3, Vec3, Vec3]:
|
|
35
|
+
"""Apply a symbolic step and return (delta, new_forward, new_up)."""
|
|
36
|
+
if sym not in ("L", "R", "U", "D"):
|
|
37
|
+
raise ValueError(f"Invalid direction symbol {sym}")
|
|
38
|
+
|
|
39
|
+
right = _cross(forward, up)
|
|
40
|
+
|
|
41
|
+
if sym == "R":
|
|
42
|
+
new_forward = forward
|
|
43
|
+
new_up = up
|
|
44
|
+
delta = new_forward
|
|
45
|
+
return delta, new_forward, new_up
|
|
46
|
+
|
|
47
|
+
if sym == "L":
|
|
48
|
+
# yaw left around up: forward -> -right
|
|
49
|
+
new_forward = _neg(right)
|
|
50
|
+
new_up = up
|
|
51
|
+
delta = new_forward
|
|
52
|
+
return delta, new_forward, new_up
|
|
53
|
+
|
|
54
|
+
if sym == "U":
|
|
55
|
+
# pitch up around right: forward -> up, up -> -forward
|
|
56
|
+
new_forward = up
|
|
57
|
+
new_up = _neg(forward)
|
|
58
|
+
delta = new_forward
|
|
59
|
+
return delta, new_forward, new_up
|
|
60
|
+
|
|
61
|
+
# sym == "D"
|
|
62
|
+
# pitch down around right: forward -> -up, up -> forward
|
|
63
|
+
new_forward = _neg(up)
|
|
64
|
+
new_up = forward
|
|
65
|
+
delta = new_forward
|
|
66
|
+
return delta, new_forward, new_up
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def infer_dirs_from_path(vertices: Iterable[Vec3], edges: Iterable[tuple[int, int]]) -> List[str]:
|
|
70
|
+
"""Infer L/R/U/D symbols from a path using the local-frame rules."""
|
|
71
|
+
verts = list(vertices)
|
|
72
|
+
eds = list(edges)
|
|
73
|
+
if not verts:
|
|
74
|
+
return []
|
|
75
|
+
if not eds:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
forward: Vec3 = (0, 0, 1)
|
|
79
|
+
up: Vec3 = (0, 1, 0)
|
|
80
|
+
|
|
81
|
+
dirs: List[str] = []
|
|
82
|
+
for from_idx, to_idx in eds:
|
|
83
|
+
fv = verts[from_idx]
|
|
84
|
+
tv = verts[to_idx]
|
|
85
|
+
vec: Vec3 = (tv[0] - fv[0], tv[1] - fv[1], tv[2] - fv[2])
|
|
86
|
+
|
|
87
|
+
right = _cross(forward, up)
|
|
88
|
+
candidates: Dict[str, Vec3] = {
|
|
89
|
+
"R": forward,
|
|
90
|
+
"L": _neg(right),
|
|
91
|
+
"U": up,
|
|
92
|
+
"D": _neg(up),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
sym = None
|
|
96
|
+
for k, v in candidates.items():
|
|
97
|
+
if vec == v:
|
|
98
|
+
sym = k
|
|
99
|
+
break
|
|
100
|
+
if sym is None:
|
|
101
|
+
raise ValueError(f"Unknown step vector {vec}, cannot infer direction symbol")
|
|
102
|
+
|
|
103
|
+
dirs.append(sym)
|
|
104
|
+
_, forward, up = apply_step(sym, forward, up)
|
|
105
|
+
|
|
106
|
+
return dirs
|
|
107
|
+
|
bin2path/path.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Core data structures for bin2path."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class PathMetadata:
|
|
8
|
+
"""Metadata for a 3D path."""
|
|
9
|
+
original_number: int
|
|
10
|
+
bits_length: int
|
|
11
|
+
first_one_pos: int = 0 # position of first 1-bit (number of leading zeros)
|
|
12
|
+
step_positions: list[int] = None # positions of all 1-bits
|
|
13
|
+
# Start forward direction used by the encoding/decoding scheme (defaults to +Z)
|
|
14
|
+
start_direction: tuple[int, int, int] = (0, 0, 1)
|
|
15
|
+
|
|
16
|
+
def __post_init__(self):
|
|
17
|
+
if self.step_positions is None:
|
|
18
|
+
self.step_positions = []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Path3D:
|
|
23
|
+
"""
|
|
24
|
+
3D path representation.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
vertices: List of (x, y, z) tuples representing path vertices.
|
|
28
|
+
edges: List of (from_idx, to_idx) tuples representing directed edges.
|
|
29
|
+
metadata: Additional metadata about the path.
|
|
30
|
+
"""
|
|
31
|
+
vertices: list[tuple[int, int, int]]
|
|
32
|
+
edges: list[tuple[int, int]]
|
|
33
|
+
metadata: PathMetadata
|
|
34
|
+
|
|
35
|
+
def __post_init__(self):
|
|
36
|
+
if not self.vertices:
|
|
37
|
+
raise ValueError("Path must have at least one vertex")
|
|
38
|
+
if len(self.edges) != len(self.vertices) - 1:
|
|
39
|
+
raise ValueError("Number of edges must be equal to vertices count minus 1")
|
|
40
|
+
if self.metadata.bits_length < len(self.edges):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
"Invalid metadata: bits_length must be >= number of edges "
|
|
43
|
+
f"(bits_length={self.metadata.bits_length}, edges={len(self.edges)})"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def start(self) -> tuple[int, int, int]:
|
|
48
|
+
"""Return the starting vertex."""
|
|
49
|
+
return self.vertices[0] if self.vertices else (0, 0, 0)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def end(self) -> tuple[int, int, int]:
|
|
53
|
+
"""Return the ending vertex."""
|
|
54
|
+
return self.vertices[-1] if self.vertices else (0, 0, 0)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def num_steps(self) -> int:
|
|
58
|
+
"""Return the number of steps (edges) in the path."""
|
|
59
|
+
return len(self.edges)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def num_turns(self) -> int:
|
|
63
|
+
"""
|
|
64
|
+
Return the number of turn bits (0-bits) in the encoding.
|
|
65
|
+
|
|
66
|
+
In this scheme, 1-bits produce forward steps (edges), and 0-bits produce turns.
|
|
67
|
+
Since each bit corresponds to one symbolic action, the turn count is:
|
|
68
|
+
turns = bits_length - edges_count
|
|
69
|
+
"""
|
|
70
|
+
return self.metadata.bits_length - len(self.edges)
|
bin2path/serialize.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Serialize and deserialize Path3D objects."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from bin2path.path import Path3D, PathMetadata
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def serialize(path: Path3D) -> dict:
|
|
8
|
+
"""
|
|
9
|
+
Serialize a Path3D object to a dictionary (JSON-compatible).
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
path: Path3D object.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Dictionary representation.
|
|
16
|
+
"""
|
|
17
|
+
return {
|
|
18
|
+
"vertices": path.vertices,
|
|
19
|
+
"edges": path.edges,
|
|
20
|
+
"metadata": {
|
|
21
|
+
"original_number": path.metadata.original_number,
|
|
22
|
+
"bits_length": path.metadata.bits_length,
|
|
23
|
+
"first_one_pos": path.metadata.first_one_pos,
|
|
24
|
+
"step_positions": path.metadata.step_positions,
|
|
25
|
+
"start_direction": path.metadata.start_direction,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def deserialize(data: dict) -> Path3D:
|
|
31
|
+
"""
|
|
32
|
+
Deserialize a dictionary to a Path3D object.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
data: Dictionary with 'vertices', 'edges', 'metadata' keys.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path3D object.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ValueError: If data is invalid.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(data, dict):
|
|
44
|
+
raise TypeError("Data must be a dictionary")
|
|
45
|
+
|
|
46
|
+
if "vertices" not in data or "edges" not in data or "metadata" not in data:
|
|
47
|
+
raise ValueError("Data must contain 'vertices', 'edges', and 'metadata'")
|
|
48
|
+
|
|
49
|
+
vertices = data["vertices"]
|
|
50
|
+
edges = data["edges"]
|
|
51
|
+
metadata = data["metadata"]
|
|
52
|
+
|
|
53
|
+
# Validate structure
|
|
54
|
+
if not isinstance(vertices, list) or not isinstance(edges, list):
|
|
55
|
+
raise ValueError("Vertices and edges must be lists")
|
|
56
|
+
|
|
57
|
+
# Convert to tuples
|
|
58
|
+
vertices = [tuple(v) for v in vertices]
|
|
59
|
+
edges = [tuple(e) for e in edges]
|
|
60
|
+
|
|
61
|
+
path_metadata = PathMetadata(
|
|
62
|
+
original_number=metadata.get("original_number", 0),
|
|
63
|
+
bits_length=metadata.get("bits_length", 0),
|
|
64
|
+
first_one_pos=metadata.get("first_one_pos", 0),
|
|
65
|
+
step_positions=metadata.get("step_positions", []),
|
|
66
|
+
start_direction=tuple(metadata.get("start_direction", (1, 0, 0))),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return Path3D(vertices=vertices, edges=edges, metadata=path_metadata)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def to_json(path: Path3D, indent: int = 2) -> str:
|
|
73
|
+
"""
|
|
74
|
+
Convert Path3D to JSON string.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
path: Path3D object.
|
|
78
|
+
indent: JSON indentation level.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
JSON string.
|
|
82
|
+
"""
|
|
83
|
+
return json.dumps(serialize(path), indent=indent)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def from_json(json_str: str) -> Path3D:
|
|
87
|
+
"""
|
|
88
|
+
Parse JSON string to Path3D.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
json_str: JSON string.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Path3D object.
|
|
95
|
+
"""
|
|
96
|
+
data = json.loads(json_str)
|
|
97
|
+
return deserialize(data)
|
bin2path/validate.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Validate Path3D objects."""
|
|
2
|
+
|
|
3
|
+
from bin2path.path import Path3D
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate(path: Path3D) -> tuple[bool, list[str]]:
|
|
7
|
+
"""
|
|
8
|
+
Validate a Path3D object.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
path: Path3D object to validate.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
Tuple of (is_valid, list_of_errors).
|
|
15
|
+
"""
|
|
16
|
+
errors = []
|
|
17
|
+
|
|
18
|
+
# Check vertices exist
|
|
19
|
+
if not path.vertices:
|
|
20
|
+
errors.append("Path must have at least one vertex")
|
|
21
|
+
return False, errors
|
|
22
|
+
|
|
23
|
+
# Check vertices are valid tuples
|
|
24
|
+
for i, v in enumerate(path.vertices):
|
|
25
|
+
if not isinstance(v, (tuple, list)) or len(v) != 3:
|
|
26
|
+
errors.append(f"Vertex {i} must be a 3D coordinate tuple")
|
|
27
|
+
elif not all(isinstance(c, int) for c in v):
|
|
28
|
+
errors.append(f"Vertex {i} coordinates must be integers")
|
|
29
|
+
|
|
30
|
+
# Check edges exist
|
|
31
|
+
# Special-case: allow the trivial path for 0 (single vertex, no edges)
|
|
32
|
+
if not path.edges:
|
|
33
|
+
if len(path.vertices) == 1 and path.metadata and path.metadata.bits_length == 1:
|
|
34
|
+
if errors:
|
|
35
|
+
return False, errors
|
|
36
|
+
return True, []
|
|
37
|
+
errors.append("Path must have at least one edge (two vertices), unless it is the trivial 0-path")
|
|
38
|
+
return False, errors
|
|
39
|
+
|
|
40
|
+
# Check edges are valid
|
|
41
|
+
for i, e in enumerate(path.edges):
|
|
42
|
+
if not isinstance(e, (tuple, list)) or len(e) != 2:
|
|
43
|
+
errors.append(f"Edge {i} must be a tuple of two indices")
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
from_idx, to_idx = e
|
|
47
|
+
|
|
48
|
+
if from_idx < 0 or from_idx >= len(path.vertices):
|
|
49
|
+
errors.append(f"Edge {i}: from_index {from_idx} out of range")
|
|
50
|
+
if to_idx < 0 or to_idx >= len(path.vertices):
|
|
51
|
+
errors.append(f"Edge {i}: to_index {to_idx} out of range")
|
|
52
|
+
|
|
53
|
+
# Check edge direction is valid (must be one of 6 axis-aligned unit directions)
|
|
54
|
+
if from_idx < len(path.vertices) and to_idx < len(path.vertices):
|
|
55
|
+
from_v = path.vertices[from_idx]
|
|
56
|
+
to_v = path.vertices[to_idx]
|
|
57
|
+
edge_dir = (
|
|
58
|
+
to_v[0] - from_v[0],
|
|
59
|
+
to_v[1] - from_v[1],
|
|
60
|
+
to_v[2] - from_v[2],
|
|
61
|
+
)
|
|
62
|
+
if edge_dir not in {
|
|
63
|
+
(-1, 0, 0),
|
|
64
|
+
(1, 0, 0),
|
|
65
|
+
(0, 1, 0),
|
|
66
|
+
(0, -1, 0),
|
|
67
|
+
(0, 0, 1),
|
|
68
|
+
(0, 0, -1),
|
|
69
|
+
}:
|
|
70
|
+
errors.append(f"Edge {i}: invalid direction {edge_dir}")
|
|
71
|
+
|
|
72
|
+
# Check edge count matches vertices - 1
|
|
73
|
+
if len(path.edges) != len(path.vertices) - 1:
|
|
74
|
+
errors.append(f"Edges count ({len(path.edges)}) must equal vertices count ({len(path.vertices)}) - 1")
|
|
75
|
+
|
|
76
|
+
# Check metadata
|
|
77
|
+
if not path.metadata:
|
|
78
|
+
errors.append("Path must have metadata")
|
|
79
|
+
elif path.metadata.bits_length <= 0:
|
|
80
|
+
errors.append("bits_length must be positive")
|
|
81
|
+
|
|
82
|
+
# Check path is connected (simple check)
|
|
83
|
+
for i in range(len(path.edges) - 1):
|
|
84
|
+
if path.edges[i][1] != path.edges[i + 1][0]:
|
|
85
|
+
errors.append(f"Path is not continuous at edge {i}")
|
|
86
|
+
|
|
87
|
+
is_valid = len(errors) == 0
|
|
88
|
+
return is_valid, errors
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_valid(path: Path3D) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Quick validation check.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
path: Path3D object.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if valid, False otherwise.
|
|
100
|
+
"""
|
|
101
|
+
return validate(path)[0]
|