homlab-solver 0.1.0__tar.gz
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.
- homlab_solver-0.1.0/LICENSE +21 -0
- homlab_solver-0.1.0/PKG-INFO +170 -0
- homlab_solver-0.1.0/README.md +152 -0
- homlab_solver-0.1.0/homlab_solver/__init__.py +5 -0
- homlab_solver-0.1.0/homlab_solver/geo.py +90 -0
- homlab_solver-0.1.0/homlab_solver/main.py +249 -0
- homlab_solver-0.1.0/pyproject.toml +17 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Homology Lab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: homlab-solver
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 针对单个无交叉点图的 HomLab 求解算法。
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: GGN_2015
|
|
8
|
+
Author-email: neko@jlulug.org
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# homlab-solver
|
|
19
|
+
|
|
20
|
+
`homlab-solver` is a small Python package for solving a single non-intersecting
|
|
21
|
+
HomLab diagram stored as JSON.
|
|
22
|
+
|
|
23
|
+
The package currently exposes a Python API. It does not define a command-line
|
|
24
|
+
entry point yet.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- Python 3.10 or newer
|
|
29
|
+
- No runtime dependencies outside the Python standard library
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
After the package is published to PyPI:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m pip install homlab-solver
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For local development from this repository:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python -m pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import random
|
|
49
|
+
|
|
50
|
+
from homlab_solver import solve_single_json
|
|
51
|
+
|
|
52
|
+
rng = random.Random(42)
|
|
53
|
+
result = solve_single_json("test.json", rng=rng)
|
|
54
|
+
|
|
55
|
+
if result.msg:
|
|
56
|
+
raise RuntimeError(result.msg)
|
|
57
|
+
|
|
58
|
+
print(result.ans)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
You can run the same example from a shell:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python - <<'PY'
|
|
65
|
+
import random
|
|
66
|
+
from homlab_solver import solve_single_json
|
|
67
|
+
|
|
68
|
+
result = solve_single_json("test.json", rng=random.Random(42))
|
|
69
|
+
|
|
70
|
+
if result.msg:
|
|
71
|
+
raise SystemExit(result.msg)
|
|
72
|
+
|
|
73
|
+
print(result.ans)
|
|
74
|
+
PY
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With the sample `test.json` in this repository, the output is:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
(-A^2-A^(-2)) * C[-1, 1, -2]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## API
|
|
84
|
+
|
|
85
|
+
### `solve_single_json(filepath, rng, loop_value="(-A^2-A^(-2))", eps=1e-8)`
|
|
86
|
+
|
|
87
|
+
Solves one HomLab JSON file.
|
|
88
|
+
|
|
89
|
+
Parameters:
|
|
90
|
+
|
|
91
|
+
- `filepath`: path to a HomLab JSON file.
|
|
92
|
+
- `rng`: a `random.Random` instance used for small geometric perturbations and
|
|
93
|
+
randomized ray tests. Pass a seeded instance when you need reproducible output.
|
|
94
|
+
- `loop_value`: symbolic value used for each free loop.
|
|
95
|
+
- `eps`: floating-point tolerance for geometric predicates.
|
|
96
|
+
|
|
97
|
+
Returns a `SolverObject` with:
|
|
98
|
+
|
|
99
|
+
- `ans`: the computed symbolic answer, or `None` if solving failed.
|
|
100
|
+
- `msg`: an empty string on success, or an error message on failure.
|
|
101
|
+
|
|
102
|
+
## Input JSON
|
|
103
|
+
|
|
104
|
+
The solver expects a JSON object describing a single HomLab diagram. At minimum,
|
|
105
|
+
the file must include:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"type": "homlab",
|
|
110
|
+
"n": 2,
|
|
111
|
+
"hs": {
|
|
112
|
+
"handle_genus_0000000": {"x": 0.5, "y": 0.35},
|
|
113
|
+
"handle_genus_0000001": {"x": 1.35, "y": 0.35},
|
|
114
|
+
"handle_border_0000000": {"x": 0.5, "y": -0.2},
|
|
115
|
+
"handle_border_0000001": {"x": 1.35, "y": -0.2}
|
|
116
|
+
},
|
|
117
|
+
"ns": {
|
|
118
|
+
"node_0000000": {"x": 0.0, "y": 0.0}
|
|
119
|
+
},
|
|
120
|
+
"es": {
|
|
121
|
+
"edge_0000000": {
|
|
122
|
+
"id_1": "handle_genus_0000000",
|
|
123
|
+
"id_2": "node_0000000"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Important constraints:
|
|
130
|
+
|
|
131
|
+
- `type` must be `"homlab"`.
|
|
132
|
+
- `n` must be `2`.
|
|
133
|
+
- The four handle IDs shown above are required by the current solver.
|
|
134
|
+
- Normal node IDs should start with `node_`.
|
|
135
|
+
- Handle IDs should start with `handle_`.
|
|
136
|
+
- Edge objects must contain `id_1` and `id_2`.
|
|
137
|
+
- IDs starting with `#` are treated as special metadata and ignored by the
|
|
138
|
+
solving step.
|
|
139
|
+
- Non-handle nodes are expected to have degree `2`.
|
|
140
|
+
- Handle nodes are expected to have degree `0` or `1`.
|
|
141
|
+
|
|
142
|
+
## Building
|
|
143
|
+
|
|
144
|
+
This project uses `poetry-core` as its build backend. To build a source
|
|
145
|
+
distribution and wheel:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python -m pip install --upgrade build
|
|
149
|
+
python -m build
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The generated artifacts will be written to `dist/`.
|
|
153
|
+
|
|
154
|
+
Before uploading to PyPI, check the package metadata:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
python -m pip install --upgrade twine
|
|
158
|
+
python -m twine check dist/*
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Upload to PyPI:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
python -m twine upload dist/*
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
This project is licensed under the MIT License. See `LICENSE` for details.
|
|
170
|
+
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# homlab-solver
|
|
2
|
+
|
|
3
|
+
`homlab-solver` is a small Python package for solving a single non-intersecting
|
|
4
|
+
HomLab diagram stored as JSON.
|
|
5
|
+
|
|
6
|
+
The package currently exposes a Python API. It does not define a command-line
|
|
7
|
+
entry point yet.
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- Python 3.10 or newer
|
|
12
|
+
- No runtime dependencies outside the Python standard library
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
After the package is published to PyPI:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install homlab-solver
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
For local development from this repository:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python -m pip install -e .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import random
|
|
32
|
+
|
|
33
|
+
from homlab_solver import solve_single_json
|
|
34
|
+
|
|
35
|
+
rng = random.Random(42)
|
|
36
|
+
result = solve_single_json("test.json", rng=rng)
|
|
37
|
+
|
|
38
|
+
if result.msg:
|
|
39
|
+
raise RuntimeError(result.msg)
|
|
40
|
+
|
|
41
|
+
print(result.ans)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You can run the same example from a shell:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python - <<'PY'
|
|
48
|
+
import random
|
|
49
|
+
from homlab_solver import solve_single_json
|
|
50
|
+
|
|
51
|
+
result = solve_single_json("test.json", rng=random.Random(42))
|
|
52
|
+
|
|
53
|
+
if result.msg:
|
|
54
|
+
raise SystemExit(result.msg)
|
|
55
|
+
|
|
56
|
+
print(result.ans)
|
|
57
|
+
PY
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
With the sample `test.json` in this repository, the output is:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
(-A^2-A^(-2)) * C[-1, 1, -2]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API
|
|
67
|
+
|
|
68
|
+
### `solve_single_json(filepath, rng, loop_value="(-A^2-A^(-2))", eps=1e-8)`
|
|
69
|
+
|
|
70
|
+
Solves one HomLab JSON file.
|
|
71
|
+
|
|
72
|
+
Parameters:
|
|
73
|
+
|
|
74
|
+
- `filepath`: path to a HomLab JSON file.
|
|
75
|
+
- `rng`: a `random.Random` instance used for small geometric perturbations and
|
|
76
|
+
randomized ray tests. Pass a seeded instance when you need reproducible output.
|
|
77
|
+
- `loop_value`: symbolic value used for each free loop.
|
|
78
|
+
- `eps`: floating-point tolerance for geometric predicates.
|
|
79
|
+
|
|
80
|
+
Returns a `SolverObject` with:
|
|
81
|
+
|
|
82
|
+
- `ans`: the computed symbolic answer, or `None` if solving failed.
|
|
83
|
+
- `msg`: an empty string on success, or an error message on failure.
|
|
84
|
+
|
|
85
|
+
## Input JSON
|
|
86
|
+
|
|
87
|
+
The solver expects a JSON object describing a single HomLab diagram. At minimum,
|
|
88
|
+
the file must include:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"type": "homlab",
|
|
93
|
+
"n": 2,
|
|
94
|
+
"hs": {
|
|
95
|
+
"handle_genus_0000000": {"x": 0.5, "y": 0.35},
|
|
96
|
+
"handle_genus_0000001": {"x": 1.35, "y": 0.35},
|
|
97
|
+
"handle_border_0000000": {"x": 0.5, "y": -0.2},
|
|
98
|
+
"handle_border_0000001": {"x": 1.35, "y": -0.2}
|
|
99
|
+
},
|
|
100
|
+
"ns": {
|
|
101
|
+
"node_0000000": {"x": 0.0, "y": 0.0}
|
|
102
|
+
},
|
|
103
|
+
"es": {
|
|
104
|
+
"edge_0000000": {
|
|
105
|
+
"id_1": "handle_genus_0000000",
|
|
106
|
+
"id_2": "node_0000000"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Important constraints:
|
|
113
|
+
|
|
114
|
+
- `type` must be `"homlab"`.
|
|
115
|
+
- `n` must be `2`.
|
|
116
|
+
- The four handle IDs shown above are required by the current solver.
|
|
117
|
+
- Normal node IDs should start with `node_`.
|
|
118
|
+
- Handle IDs should start with `handle_`.
|
|
119
|
+
- Edge objects must contain `id_1` and `id_2`.
|
|
120
|
+
- IDs starting with `#` are treated as special metadata and ignored by the
|
|
121
|
+
solving step.
|
|
122
|
+
- Non-handle nodes are expected to have degree `2`.
|
|
123
|
+
- Handle nodes are expected to have degree `0` or `1`.
|
|
124
|
+
|
|
125
|
+
## Building
|
|
126
|
+
|
|
127
|
+
This project uses `poetry-core` as its build backend. To build a source
|
|
128
|
+
distribution and wheel:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
python -m pip install --upgrade build
|
|
132
|
+
python -m build
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The generated artifacts will be written to `dist/`.
|
|
136
|
+
|
|
137
|
+
Before uploading to PyPI, check the package metadata:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python -m pip install --upgrade twine
|
|
141
|
+
python -m twine check dist/*
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Upload to PyPI:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
python -m twine upload dist/*
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
This project is licensed under the MIT License. See `LICENSE` for details.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
from typing import List, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def vec_sub(p1: Tuple[float, float], p2: Tuple[float, float]) -> Tuple[float, float]:
|
|
7
|
+
"""
|
|
8
|
+
两点相减得到向量: p1 - p2
|
|
9
|
+
:param p1: 起点坐标 (x, y)
|
|
10
|
+
:param p2: 终点坐标 (x, y)
|
|
11
|
+
:return: 二维向量 (dx, dy)
|
|
12
|
+
"""
|
|
13
|
+
return (p1[0] - p2[0], p1[1] - p2[1])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def vec_cross(v1: Tuple[float, float], v2: Tuple[float, float]) -> float:
|
|
17
|
+
"""
|
|
18
|
+
计算两个二维向量的叉积(标量结果)
|
|
19
|
+
:param v1: 向量1 (x1, y1)
|
|
20
|
+
:param v2: 向量2 (x2, y2)
|
|
21
|
+
:return: 叉积值 = x1*y2 - y1*x2
|
|
22
|
+
"""
|
|
23
|
+
return v1[0] * v2[1] - v1[1] * v2[0]
|
|
24
|
+
|
|
25
|
+
def ray_segment_intersect(
|
|
26
|
+
ray_origin: Tuple[float, float],
|
|
27
|
+
ray_dir: Tuple[float, float],
|
|
28
|
+
seg_p1: Tuple[float, float],
|
|
29
|
+
seg_p2: Tuple[float, float],
|
|
30
|
+
eps: float
|
|
31
|
+
) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
判断射线与线段是否相交
|
|
34
|
+
:param ray_origin: 射线起点 (x, y)
|
|
35
|
+
:param ray_dir: 射线方向向量 (dx, dy)
|
|
36
|
+
:param seg_p1: 线段端点1
|
|
37
|
+
:param seg_p2: 线段端点2
|
|
38
|
+
:param eps: 浮点精度阈值
|
|
39
|
+
:return: 相交返回 True,否则 False
|
|
40
|
+
"""
|
|
41
|
+
x0, y0 = ray_origin
|
|
42
|
+
dx_r, dy_r = ray_dir
|
|
43
|
+
x1, y1 = seg_p1
|
|
44
|
+
x2, y2 = seg_p2
|
|
45
|
+
|
|
46
|
+
den = (x2 - x1) * dy_r - (y2 - y1) * dx_r
|
|
47
|
+
if abs(den) < eps:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
t_numer = (x0 - x1) * dy_r - (y0 - y1) * dx_r
|
|
51
|
+
s_numer = (x0 - x1) * (y2 - y1) - (y0 - y1) * (x2 - x1)
|
|
52
|
+
|
|
53
|
+
t = t_numer / den
|
|
54
|
+
s = s_numer / den
|
|
55
|
+
|
|
56
|
+
# t ∈ [0,1] 在线段内,s > 0 在射线正方向
|
|
57
|
+
return (0.0 - eps <= t <= 1.0 + eps) and (s > eps)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def point_in_ring(
|
|
61
|
+
ring: List[Tuple[float, float]],
|
|
62
|
+
p: Tuple[float, float],
|
|
63
|
+
rng: random.Random,
|
|
64
|
+
eps: float = 1e-12
|
|
65
|
+
) -> bool:
|
|
66
|
+
"""
|
|
67
|
+
随机射线法判断二维点是否在简单闭合环内部
|
|
68
|
+
:param ring: 环顶点列表 [(x,y), ...],简单无自交环
|
|
69
|
+
:param p: 待检测点 (x0, y0)
|
|
70
|
+
:param rng: 外部传入的随机数生成器对象
|
|
71
|
+
:param eps: 浮点计算精度阈值
|
|
72
|
+
:return: True=点在圈内,False=点在圈外
|
|
73
|
+
"""
|
|
74
|
+
x0, y0 = p
|
|
75
|
+
intersect_count = 0
|
|
76
|
+
n = len(ring)
|
|
77
|
+
if n < 3:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# 生成随机方向射线
|
|
81
|
+
angle = rng.uniform(0.0, math.pi * 2)
|
|
82
|
+
ray_dir = (math.cos(angle), math.sin(angle))
|
|
83
|
+
|
|
84
|
+
for i in range(n):
|
|
85
|
+
p1 = ring[i]
|
|
86
|
+
p2 = ring[(i + 1) % n]
|
|
87
|
+
if ray_segment_intersect(p, ray_dir, p1, p2, eps):
|
|
88
|
+
intersect_count += 1
|
|
89
|
+
|
|
90
|
+
return intersect_count % 2 == 1
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
# 几何运算函数
|
|
8
|
+
try:
|
|
9
|
+
from .geo import ray_segment_intersect, point_in_ring, vec_sub, vec_cross
|
|
10
|
+
except:
|
|
11
|
+
from geo import ray_segment_intersect, point_in_ring, vec_sub, vec_cross
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SolverObject:
|
|
15
|
+
ans:Optional[str] = None # 计算结果
|
|
16
|
+
msg:str = "" # 错误信息(空字符串表示没有错误)
|
|
17
|
+
|
|
18
|
+
# 统计节点的 “度”
|
|
19
|
+
def count_degree_solve_nxt(ns:dict[str, dict], es:dict[str, dict]) -> tuple[dict[str, int], dict[str, set[str]]]:
|
|
20
|
+
degree_of_node = dict()
|
|
21
|
+
nxt_node_dict:dict[str, set[str]] = dict()
|
|
22
|
+
for node_id in ns:
|
|
23
|
+
degree_of_node[node_id] = 0
|
|
24
|
+
nxt_node_dict[node_id] = set() # 记录所有后继节点
|
|
25
|
+
|
|
26
|
+
# 统计节点度(连接了两个节点)
|
|
27
|
+
for edge_id in es:
|
|
28
|
+
node_id_1 = es[edge_id]["id_1"]
|
|
29
|
+
node_id_2 = es[edge_id]["id_2"]
|
|
30
|
+
degree_of_node[node_id_1] += 1
|
|
31
|
+
degree_of_node[node_id_2] += 1
|
|
32
|
+
nxt_node_dict[node_id_1].add(node_id_2)
|
|
33
|
+
nxt_node_dict[node_id_2].add(node_id_1)
|
|
34
|
+
|
|
35
|
+
# 返回节点度和所有后继节点
|
|
36
|
+
return degree_of_node, nxt_node_dict
|
|
37
|
+
|
|
38
|
+
# 给定一个 JSON 文件描述的无交叉点图
|
|
39
|
+
# 计算它对应的多项式项(目前不根据文件名分析 A 的次数)
|
|
40
|
+
def solve_single_json(filepath:str, rng:random.Random, loop_value:str="(-A^2-A^(-2))", eps=1e-8) -> SolverObject:
|
|
41
|
+
|
|
42
|
+
# 文件不存在
|
|
43
|
+
if not os.path.isfile(filepath):
|
|
44
|
+
return SolverObject(msg=f"file {filepath} not found.")
|
|
45
|
+
|
|
46
|
+
# 先试图加载出文件中的所有数据,得到 data_dict
|
|
47
|
+
try:
|
|
48
|
+
data_dict = dict()
|
|
49
|
+
with open(filepath, "r", encoding="utf-8") as fpin:
|
|
50
|
+
data_dict = json.load(fpin)
|
|
51
|
+
except Exception as err:
|
|
52
|
+
return SolverObject(msg=f"Error when reading file {filepath}: {err}.")
|
|
53
|
+
|
|
54
|
+
# 检查文件类型标记
|
|
55
|
+
if data_dict.get("type") != "homlab":
|
|
56
|
+
return SolverObject(msg=f"Type of {filepath} is not homlab.")
|
|
57
|
+
|
|
58
|
+
# ns 中记录了所有节点(包括 handle 节点)的坐标信息
|
|
59
|
+
ns = data_dict["ns"]
|
|
60
|
+
hs = data_dict["hs"] # 注意,root 的结构不是太一样,root 的结构 ns 里面没有 handle
|
|
61
|
+
for hid in hs:
|
|
62
|
+
if ns.get(hid) is None:
|
|
63
|
+
ns[hid] = {
|
|
64
|
+
"x": hs[hid]["x"], # 记录这个信息到 nid
|
|
65
|
+
"y": hs[hid]["y"]
|
|
66
|
+
}
|
|
67
|
+
es = data_dict["es"] # 记录了所有边的信息
|
|
68
|
+
|
|
69
|
+
# 叠加一个随机的小变量
|
|
70
|
+
for nid in ns:
|
|
71
|
+
if nid.startswith("#"): # 如果是一个特殊变量
|
|
72
|
+
continue
|
|
73
|
+
ns[nid]["x"] += rng.uniform(eps*-30, eps*30)
|
|
74
|
+
ns[nid]["y"] += rng.uniform(eps*-30, eps*30)
|
|
75
|
+
|
|
76
|
+
# 删掉特殊节点与特殊边
|
|
77
|
+
for node_id in [nid for nid in ns]:
|
|
78
|
+
if node_id.startswith('#'):
|
|
79
|
+
del ns[node_id]
|
|
80
|
+
for edge_id in [eid for eid in es]:
|
|
81
|
+
if edge_id.startswith('#'):
|
|
82
|
+
del es[edge_id]
|
|
83
|
+
|
|
84
|
+
# 确保图中只有两个 genus,目前我的算法不能计算两个 genus 以外的东西
|
|
85
|
+
genus_cnt = data_dict["n"]
|
|
86
|
+
if genus_cnt != 2:
|
|
87
|
+
return SolverObject(msg=f"Genus count of {filepath} is not 2.")
|
|
88
|
+
|
|
89
|
+
# 统计节点的度
|
|
90
|
+
degree_dict, nxt_node_dict = count_degree_solve_nxt(ns, es)
|
|
91
|
+
erased_node_list = []
|
|
92
|
+
for node_id in degree_dict:
|
|
93
|
+
|
|
94
|
+
# 如果一个节点没有度,那么我们就把他从 ns 中删掉
|
|
95
|
+
if degree_dict[node_id] == 0 and (not node_id.startswith("handle_")):
|
|
96
|
+
erased_node_list.append(node_id)
|
|
97
|
+
|
|
98
|
+
# 删除应该删除的节点
|
|
99
|
+
for node_id in erased_node_list:
|
|
100
|
+
del ns[node_id]
|
|
101
|
+
del degree_dict[node_id]
|
|
102
|
+
|
|
103
|
+
# vis 数组中记录遍历过程中某个节点是否已经访问过
|
|
104
|
+
vis = dict()
|
|
105
|
+
for node_id in ns:
|
|
106
|
+
vis[node_id] = 0 # 记录成没有访问过
|
|
107
|
+
|
|
108
|
+
# 开始和结束点必须是 handle_ 节点
|
|
109
|
+
# handle_ 节点度为 0 或 1,非 handle_ 节点度应该为 2(不为 2 说明线不合法)
|
|
110
|
+
for node_id in degree_dict:
|
|
111
|
+
|
|
112
|
+
# 抓手节点的度应该为 0 或者 1
|
|
113
|
+
if node_id.startswith("handle_"):
|
|
114
|
+
if degree_dict[node_id] not in [0, 1]:
|
|
115
|
+
return SolverObject(msg=f"Degree of {node_id} ({degree_dict[node_id]}) not in [0, 1].")
|
|
116
|
+
|
|
117
|
+
# 普通节点的度应该为 2
|
|
118
|
+
else:
|
|
119
|
+
assert node_id.startswith("node_")
|
|
120
|
+
if degree_dict[node_id] != 2:
|
|
121
|
+
return SolverObject(msg=f"Degree of {node_id} ({degree_dict[node_id]}) != 2.")
|
|
122
|
+
|
|
123
|
+
# 确定图片中 “下” 的方向
|
|
124
|
+
# 用 0 号节点,从 handle_border 指向 handle_genus_ 确定
|
|
125
|
+
# 从 border 指向 genus 的方向(down_x 和 down_y 中必然有一个 0)
|
|
126
|
+
down_x = (hs["handle_genus_0000000"]["x"] - hs["handle_border_0000000"]["x"])
|
|
127
|
+
down_y = (hs["handle_genus_0000000"]["y"] - hs["handle_border_0000000"]["y"])
|
|
128
|
+
|
|
129
|
+
# 记录右侧的方向
|
|
130
|
+
# 这里可以从左指向右侧
|
|
131
|
+
right_x = (hs["handle_genus_0000001"]["x"] - hs["handle_genus_0000000"]["x"])
|
|
132
|
+
right_y = (hs["handle_genus_0000001"]["y"] - hs["handle_genus_0000000"]["y"])
|
|
133
|
+
|
|
134
|
+
# 这个向量必须是指向平面外
|
|
135
|
+
# 理论上是向下的但是这里手性反转了,扎心
|
|
136
|
+
assert vec_cross((right_x, right_y), (down_x, down_y)) > 0
|
|
137
|
+
|
|
138
|
+
# 记录所有链,将节点编号映射到链编号与链内序号
|
|
139
|
+
def _node_dfs(node_id_now:str, chain_now:list):
|
|
140
|
+
if vis[node_id_now] != 0: # 每个节点至多访问一次
|
|
141
|
+
return
|
|
142
|
+
vis[node_id_now] = 1
|
|
143
|
+
chain_now.append(node_id_now) # 在两侧的节点递归
|
|
144
|
+
for node_id in nxt_node_dict[node_id_now]:
|
|
145
|
+
_node_dfs(node_id, chain_now)
|
|
146
|
+
|
|
147
|
+
# 记录所有链条
|
|
148
|
+
# 先遍历那些从 genus 开头的链条
|
|
149
|
+
chain_list = []
|
|
150
|
+
loop_list = []
|
|
151
|
+
for handle_id in hs:
|
|
152
|
+
if handle_id.startswith("handle_genus"): # 我们从 genus 出发向外遍历
|
|
153
|
+
if vis[handle_id] == 0 and degree_dict[handle_id] >= 1:
|
|
154
|
+
chain_list.append([])
|
|
155
|
+
_node_dfs(handle_id, chain_list[-1]) # 在新建的 list 上面做遍历
|
|
156
|
+
|
|
157
|
+
# 继续试图遍历那些度为 2 的节点
|
|
158
|
+
# 这样可以得到所有的环
|
|
159
|
+
for node_id in ns:
|
|
160
|
+
if not vis[node_id] and degree_dict[node_id] == 2:
|
|
161
|
+
loop_list.append([])
|
|
162
|
+
_node_dfs(node_id, loop_list[-1]) # 在新建的 list 上面做遍历
|
|
163
|
+
|
|
164
|
+
# 将点的名字序列映射成坐标序列
|
|
165
|
+
def _get_coord_from_chain(nid_list:list[str]) -> list[tuple[float, float]]:
|
|
166
|
+
return [
|
|
167
|
+
(ns[nid]["x"], ns[nid]["y"]) # 获取坐标
|
|
168
|
+
for nid in nid_list
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
# 获取 1 号 genus 的坐标(左侧的东西是零号 genus)
|
|
172
|
+
genus1_x = hs["handle_genus_0000001"]["x"]
|
|
173
|
+
genus1_y = hs["handle_genus_0000001"]["y"]
|
|
174
|
+
|
|
175
|
+
# 记录有多少个会影响 C 的圈
|
|
176
|
+
c_second = 0
|
|
177
|
+
loop_free = 0 # 记录有多少个可缩的东西
|
|
178
|
+
|
|
179
|
+
# 对于所有的环,我们需要判断空心环有多少个
|
|
180
|
+
# 包含了 0 degree 的 handle_genus_0000001 的环有多少个,两者分别统计
|
|
181
|
+
for loop in loop_list:
|
|
182
|
+
coord_of_loop = _get_coord_from_chain(loop)
|
|
183
|
+
if point_in_ring(coord_of_loop, (genus1_x, genus1_y), rng): # 这是一个会影响 C 的圈
|
|
184
|
+
c_second += 1
|
|
185
|
+
else:
|
|
186
|
+
loop_free += 1
|
|
187
|
+
|
|
188
|
+
# 我们需要给所有在 chain 上面的点进行编号
|
|
189
|
+
# 这样我们就可以通过相邻的 node 的 rank 的大小确定走向
|
|
190
|
+
node_rank = 0
|
|
191
|
+
node_map_to_node_rank:dict[str, int] = dict()
|
|
192
|
+
for chain in chain_list:
|
|
193
|
+
for nid in chain: # 按照 chain 上面的顺序递增
|
|
194
|
+
node_rank += 1
|
|
195
|
+
node_map_to_node_rank[nid] = node_rank
|
|
196
|
+
|
|
197
|
+
crs_cnt = []
|
|
198
|
+
|
|
199
|
+
# 我们需要计算 genus0 和 genus1 下方的所有跨越
|
|
200
|
+
for px, py in [
|
|
201
|
+
(hs["handle_genus_0000000"]["x"], hs["handle_genus_0000000"]["y"]),
|
|
202
|
+
(hs["handle_genus_0000001"]["x"], hs["handle_genus_0000001"]["y"])
|
|
203
|
+
]:
|
|
204
|
+
crs_cnt.append(0) # 这里记录一个新的跨越数量
|
|
205
|
+
for eid in es:
|
|
206
|
+
nid_1 = es[eid]["id_1"]
|
|
207
|
+
nid_2 = es[eid]["id_2"]
|
|
208
|
+
# 跳过那些不在 chain 上的东西
|
|
209
|
+
if node_map_to_node_rank.get(nid_1) is None or node_map_to_node_rank.get(nid_2) is None:
|
|
210
|
+
continue
|
|
211
|
+
if node_map_to_node_rank[nid_1] >= node_map_to_node_rank[nid_2]: # 保证前进方向从 nid_1 到 nid_2
|
|
212
|
+
nid_1, nid_2 = nid_2, nid_1
|
|
213
|
+
assert node_map_to_node_rank[nid_1] < node_map_to_node_rank[nid_2]
|
|
214
|
+
n1_x = ns[nid_1]["x"] # 获取两个节点的坐标
|
|
215
|
+
n1_y = ns[nid_1]["y"]
|
|
216
|
+
n2_x = ns[nid_2]["x"]
|
|
217
|
+
n2_y = ns[nid_2]["y"]
|
|
218
|
+
# 计算与射线相交的线段
|
|
219
|
+
if ray_segment_intersect(
|
|
220
|
+
(px, py), (down_x, down_y), (n1_x, n1_y), (n2_x, n2_y), eps=eps):
|
|
221
|
+
|
|
222
|
+
# 获得一个从 genus 上端到起始节点的向量
|
|
223
|
+
vec_to_node = vec_sub((n1_x, n1_y), (px, py))
|
|
224
|
+
if vec_cross(vec_to_node, (down_x, down_y)) > 0:
|
|
225
|
+
crs_cnt[-1] = crs_cnt[-1] - 1 # 从右到左的 -1, 从左到右是 +1,行为和 right 一致的是 -1
|
|
226
|
+
else:
|
|
227
|
+
crs_cnt[-1] = crs_cnt[-1] + 1
|
|
228
|
+
|
|
229
|
+
# 计算编码
|
|
230
|
+
crs_cnt[0] -= crs_cnt[1]
|
|
231
|
+
crs_value = f"C[{crs_cnt[0]}, {c_second}, {crs_cnt[1]}]"
|
|
232
|
+
|
|
233
|
+
if loop_free == 0:
|
|
234
|
+
return SolverObject(
|
|
235
|
+
ans=crs_value
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
if loop_free == 1:
|
|
239
|
+
return SolverObject(
|
|
240
|
+
ans=loop_value + f" * " + crs_value
|
|
241
|
+
)
|
|
242
|
+
else:
|
|
243
|
+
return SolverObject(
|
|
244
|
+
ans=loop_value + f"^{loop_free} * " + crs_value
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
rng = random.Random(42)
|
|
249
|
+
print(solve_single_json("root.json", rng=rng))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "homlab-solver"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "针对单个无交叉点图的 HomLab 求解算法。"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "GGN_2015",email = "neko@jlulug.org"}
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
17
|
+
build-backend = "poetry.core.masonry.api"
|