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.
@@ -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,5 @@
1
+ from .main import solve_single_json
2
+
3
+ __all__ = [
4
+ "solve_single_json"
5
+ ]
@@ -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"