stbkit 0.1.0a9.dev1__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.
Files changed (125) hide show
  1. stbkit/__main__.py +10 -0
  2. stbkit/api/__init__.py +48 -0
  3. stbkit/api/experimental/__init__.py +19 -0
  4. stbkit/api/experimental/repository/__init__.py +30 -0
  5. stbkit/api/py.typed +0 -0
  6. stbkit/api/stb_latest.py +7 -0
  7. stbkit/api/stb_v2_0_2.py +11 -0
  8. stbkit/api/stb_v2_1_1.py +9 -0
  9. stbkit/cli/__init__.py +17 -0
  10. stbkit/cli/_internal/cli_main.py +114 -0
  11. stbkit/cli/_internal/common.py +126 -0
  12. stbkit/cli/_internal/constants_exit_code.py +59 -0
  13. stbkit/cli/_internal/converter.py +693 -0
  14. stbkit/cli/_internal/diff.py +213 -0
  15. stbkit/cli/_internal/entry_points.py +25 -0
  16. stbkit/cli/_internal/global_parent.py +70 -0
  17. stbkit/cli/_internal/show_versions.py +36 -0
  18. stbkit/cli/_internal/validate.py +310 -0
  19. stbkit/cli/commands/convert.py +16 -0
  20. stbkit/cli/commands/diff.py +16 -0
  21. stbkit/cli/commands/validate.py +16 -0
  22. stbkit/cli/py.typed +0 -0
  23. stbkit/tools/__init__.py +12 -0
  24. stbkit/tools/_internal/__init__.py +5 -0
  25. stbkit/tools/_internal/constants.py +21 -0
  26. stbkit/tools/_internal/data_model/building_geometry.py +1208 -0
  27. stbkit/tools/_internal/data_model/building_geometry_web.py +106 -0
  28. stbkit/tools/_internal/data_model/element_data.py +93 -0
  29. stbkit/tools/_internal/data_model/ifc_data/entity_data.py +67 -0
  30. stbkit/tools/_internal/data_model/ifc_data/entity_names.py +32 -0
  31. stbkit/tools/_internal/data_model/shape_data.py +1089 -0
  32. stbkit/tools/_internal/diff/__init__.py +5 -0
  33. stbkit/tools/_internal/diff/common.py +50 -0
  34. stbkit/tools/_internal/diff/diff_xml.py +796 -0
  35. stbkit/tools/_internal/matrix.py +51 -0
  36. stbkit/tools/_internal/optional_dependencies/__init__.py +5 -0
  37. stbkit/tools/_internal/optional_dependencies/ifc_common.py +28 -0
  38. stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_entity_exporter.py +473 -0
  39. stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_profile_exporter.py +267 -0
  40. stbkit/tools/_internal/optional_dependencies/ifc_exporter/ifc_representation_exporter.py +178 -0
  41. stbkit/tools/_internal/optional_dependencies/ifc_pipeline.py +133 -0
  42. stbkit/tools/_internal/optional_dependencies/ifc_to_building_geometry.py +64 -0
  43. stbkit/tools/_internal/stb_io.py +116 -0
  44. stbkit/tools/_internal/stb_repair/_common.py +24 -0
  45. stbkit/tools/_internal/stb_repair/_stb_repair.py +197 -0
  46. stbkit/tools/_internal/stb_repair/_stb_v2_0_2_repair.py +53 -0
  47. stbkit/tools/_internal/stb_repair/_stb_v2_1_0_repair.py +109 -0
  48. stbkit/tools/_internal/stb_repair/_stb_v2_1_1_repair.py +109 -0
  49. stbkit/tools/_internal/studio_report.py +49 -0
  50. stbkit/tools/_internal/utils/atomic_writer.py +57 -0
  51. stbkit/tools/_internal/utils/float_rounding.py +49 -0
  52. stbkit/tools/_internal/utils/girder_utils.py +91 -0
  53. stbkit/tools/_internal/utils/guid_utils.py +30 -0
  54. stbkit/tools/_internal/utils/mesh_utils.py +1305 -0
  55. stbkit/tools/_internal/utils/node_utils.py +50 -0
  56. stbkit/tools/_internal/utils/unit_utils.py +23 -0
  57. stbkit/tools/_internal/vectors.py +222 -0
  58. stbkit/tools/converters/__init__.py +39 -0
  59. stbkit/tools/converters/_internal/__init__.py +5 -0
  60. stbkit/tools/converters/_internal/building_geometry_to_ply_text.py +32 -0
  61. stbkit/tools/converters/_internal/coord_converter.py +151 -0
  62. stbkit/tools/converters/_internal/element_data_to_building_geometry.py +549 -0
  63. stbkit/tools/converters/_internal/ifc_to_ply_text.py +21 -0
  64. stbkit/tools/converters/_internal/name_converter.py +23 -0
  65. stbkit/tools/converters/_internal/pipeline/__init__.py +5 -0
  66. stbkit/tools/converters/_internal/pipeline/data.py +188 -0
  67. stbkit/tools/converters/_internal/pipeline/execution.py +569 -0
  68. stbkit/tools/converters/_internal/pipeline/ply_pipeline.py +52 -0
  69. stbkit/tools/converters/_internal/pipeline/stb_pipeline.py +226 -0
  70. stbkit/tools/converters/_internal/pipeline/stb_pipeline_latest.py +39 -0
  71. stbkit/tools/converters/_internal/pipeline/step.py +139 -0
  72. stbkit/tools/converters/_internal/stb_latest_to_element_data/__init__.py +17 -0
  73. stbkit/tools/converters/_internal/stb_latest_to_element_data/_stb_latest_to_element_data.py +186 -0
  74. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_footing.py +49 -0
  75. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_line.py +533 -0
  76. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_pile.py +100 -0
  77. stbkit/tools/converters/_internal/stb_latest_to_element_data/member/member_plane.py +103 -0
  78. stbkit/tools/converters/_internal/stb_latest_to_element_data/open.py +70 -0
  79. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec.py +214 -0
  80. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_rc.py +67 -0
  81. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_s.py +97 -0
  82. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_beam_src.py +28 -0
  83. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_brace_s.py +49 -0
  84. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_cft.py +217 -0
  85. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_rc.py +41 -0
  86. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_s.py +127 -0
  87. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_column_src.py +173 -0
  88. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_foundation.py +225 -0
  89. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_parapet_rc.py +53 -0
  90. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_pile_rc.py +106 -0
  91. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_pile_s.py +100 -0
  92. stbkit/tools/converters/_internal/stb_latest_to_element_data/section/sec_steel.py +432 -0
  93. stbkit/tools/converters/_internal/stb_to_building_geometry.py +44 -0
  94. stbkit/tools/converters/_internal/stb_to_ifc_data/__init__.py +5 -0
  95. stbkit/tools/converters/_internal/stb_to_ifc_data/_stb_to_ifc_data.py +59 -0
  96. stbkit/tools/converters/_internal/stb_to_ifc_data/convert_option.py +22 -0
  97. stbkit/tools/converters/_internal/stb_to_ply_text.py +22 -0
  98. stbkit/tools/py.typed +0 -0
  99. stbkit/tools/upgrade/__init__.py +13 -0
  100. stbkit/tools/upgrade/_internal/__init__.py +5 -0
  101. stbkit/tools/upgrade/_internal/common.py +205 -0
  102. stbkit/tools/upgrade/_internal/upgrade.py +228 -0
  103. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/__init__.py +60 -0
  104. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_common.py +62 -0
  105. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_joint_beam_shape_h.py +44 -0
  106. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_pile_rc.py +101 -0
  107. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_pile_s.py +78 -0
  108. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_slab_deck.py +56 -0
  109. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/hook_sec_slab_rc.py +128 -0
  110. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_girder.py +126 -0
  111. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_open.py +110 -0
  112. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_sec_beam_rc.py +227 -0
  113. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hook_funcs/post_sec_beam_s.py +1153 -0
  114. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/hooks.py +22 -0
  115. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/post.py +42 -0
  116. stbkit/tools/upgrade/_internal/v2_0_2_to_v2_1_0/tables.py +99 -0
  117. stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/__init__.py +58 -0
  118. stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/hooks.py +32 -0
  119. stbkit/tools/upgrade/_internal/v2_1_0_to_v2_1_1/tables.py +56 -0
  120. stbkit-0.1.0a9.dev1.dist-info/METADATA +196 -0
  121. stbkit-0.1.0a9.dev1.dist-info/RECORD +125 -0
  122. stbkit-0.1.0a9.dev1.dist-info/WHEEL +5 -0
  123. stbkit-0.1.0a9.dev1.dist-info/entry_points.txt +2 -0
  124. stbkit-0.1.0a9.dev1.dist-info/licenses/LICENSE +373 -0
  125. stbkit-0.1.0a9.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1305 @@
1
+ # Copyright 2025-2026 TAISEI CORPORATION
2
+ # SPDX-License-Identifier: MPL-2.0
3
+ # This Source Code Form is subject to the terms of the Mozilla Public
4
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
5
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
6
+
7
+ import itertools
8
+ import math
9
+ import warnings
10
+ from dataclasses import dataclass
11
+
12
+ from ..vectors import Vector2d
13
+
14
+ __all__ = ["MeshTriangulationWarning", "create_mesh"]
15
+
16
+
17
+ class MeshTriangulationWarning(UserWarning):
18
+ """開口を含んだ三角形分割に失敗し、開口を考慮しないことを示す警告。"""
19
+
20
+
21
+ class _TriangulationError(RuntimeError):
22
+ """内部処理が継続できない場合に投げる例外。"""
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class _Edge:
27
+ """開口の辺"""
28
+
29
+ a: Vector2d
30
+ b: Vector2d
31
+ ring_index: int
32
+ edge_index: int
33
+
34
+ def is_intersecting(self, y: float) -> bool:
35
+ """水平線が辺とぶつかる場合にTrueを返す。"""
36
+ return min(self.a.y, self.b.y) < y < max(self.a.y, self.b.y)
37
+
38
+ def intersection_x(self, y: float, eps: float) -> float:
39
+ """辺と水平線との交点X座標"""
40
+ if abs(y - self.a.y) <= eps:
41
+ return self.a.x
42
+ if abs(y - self.b.y) <= eps:
43
+ return self.b.x
44
+
45
+ dy: float = self.b.y - self.a.y
46
+ if abs(dy) <= eps:
47
+ raise _TriangulationError("水平辺の交点X座標は計算不可")
48
+
49
+ t: float = (y - self.a.y) / dy
50
+ parameter_tolerance: float = eps / max(abs(dy), eps)
51
+ if t < -parameter_tolerance or t > 1.0 + parameter_tolerance:
52
+ raise _TriangulationError("交点が編の範囲外")
53
+
54
+ t = min(1.0, max(0.0, t))
55
+ return self.a.x + t * (self.b.x - self.a.x)
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class _BandInterval:
60
+ """水平帯中央の、左右の辺を持つX区間。"""
61
+
62
+ left_edge: _Edge
63
+ right_edge: _Edge
64
+ left_x: float
65
+ right_x: float
66
+
67
+
68
+ class _VertexPool:
69
+ """許容誤差内の点を同じ頂点インデックスへまとめるクラス。"""
70
+
71
+ def __init__(self, eps: float) -> None:
72
+ self._eps: float = max(eps / 2.0, 1.0e-13)
73
+ self.points: list[Vector2d] = []
74
+ self._bins: dict[tuple[int, int], list[int]] = {}
75
+
76
+ def add(self, point: Vector2d) -> int:
77
+ """頂点を追加し、既存頂点と一致する場合は既存番号を返す。"""
78
+ size: float = self._eps
79
+ key: tuple[int, int] = (math.floor(point.x / size), math.floor(point.y / size))
80
+ limit: float = size * size
81
+
82
+ for dx in (-1, 0, 1):
83
+ for dy in (-1, 0, 1):
84
+ nearby = self._bins.get((key[0] + dx, key[1] + dy), ())
85
+ for index in nearby:
86
+ if _distance_2(self.points[index], point) <= limit:
87
+ return index
88
+
89
+ index = len(self.points)
90
+ self.points.append(point)
91
+ self._bins.setdefault(key, []).append(index)
92
+ return index
93
+
94
+
95
+ def create_mesh(
96
+ out_points: list[Vector2d],
97
+ in_points: list[list[Vector2d]] | None = None,
98
+ *,
99
+ merge_tolerance: float | None = None,
100
+ ) -> tuple[
101
+ list[Vector2d],
102
+ list[tuple[int, int, int]],
103
+ list[list[Vector2d]] | None,
104
+ list[list[Vector2d]] | None,
105
+ ]:
106
+ """開口付きの形状を三角形分割する。
107
+
108
+ 外形と各開口を水平帯に分割し、帯ごとに集合演算を行う。
109
+
110
+ 外形のX区間 - 全開口X区間の和集合
111
+
112
+ 開口が外形からはみ出す場合、外形内部にある部分のみ差し引く。
113
+ 外形に接触する開口は切り欠きとなり、重なる複数開口は一つの開口領域として処理する。
114
+
115
+ 今回の分割用途は描画用でありFEMモデル作成用ではないため、メッシュ品質にはこだわらない。
116
+ 処理も効率的なものではないが、今回対象としているものは断面の多角形や
117
+ 床や壁の開口など、頂点数は多くても2桁のオーダーであるため、そこまでの負荷にはならなと考える。
118
+
119
+ Args:
120
+ out_points: 外形
121
+ in_points: 開口の一覧
122
+ merge_tolerance: 同じ頂点として扱う許容誤差。
123
+ 連続する近接点の除去、頂点の共有、接触判定に使用する。
124
+ 省略時は、外形の幅と高さの大きい方x1e-10。
125
+
126
+ Returns:
127
+ 次の4要素のtupleを返す。
128
+
129
+ vertices: 分割した頂点。
130
+ faces: 分割した三角形の頂点インデックス一覧。
131
+ new_out_points:
132
+ 開口が外形へ統合されるなどが生じて、外形が変化した場合の外形。
133
+ 変化がなければNone。
134
+ 開口が外形を横断して領域が分断された場合は、複数返す。
135
+ 外形全体が開口で除去された場合は空リスト[]。
136
+ new_in_points:
137
+ クリップ、結合、外形への統合、完全な外側開口の除外などにより
138
+ 開口が変化した場合の新しい開口一覧。
139
+ 変化がなければNone。
140
+ 開口が残らない場合は空リスト[]。
141
+
142
+ 警告:
143
+ MeshTriangulationWarning: 開口処理に失敗した場合に投げる。
144
+ 開口を無視し、外形だけを分割する。この場合new_in_pointsは空リスト[]になる。
145
+
146
+ Raises:
147
+ ValueError:
148
+ 外形だけの分割にも失敗した場合、またはmerge_toleranceがおかしい場合に投げる
149
+ """
150
+ try:
151
+ return _run(out_points, in_points or [], merge_tolerance)
152
+ except _TriangulationError as error:
153
+ if not in_points:
154
+ raise ValueError(f"外形の三角形分割に失敗しました: {error}") from error
155
+
156
+ warnings.warn(
157
+ f"開口を含む三角形分割に失敗したため、開口を無視して外形のみ分割します。 {error}",
158
+ MeshTriangulationWarning,
159
+ stacklevel=2,
160
+ )
161
+
162
+ try:
163
+ vertices, faces, new_outer, _ = _run(
164
+ out_points,
165
+ [],
166
+ merge_tolerance,
167
+ )
168
+ except _TriangulationError as error:
169
+ raise ValueError(f"外形の三角形分割も失敗しました: {error}") from error
170
+
171
+ # 入力された開口除外されたため、[]を返して変更を明示。
172
+ return vertices, faces, new_outer, []
173
+
174
+
175
+ def _run(
176
+ out_points: list[Vector2d],
177
+ in_points: list[list[Vector2d]],
178
+ merge_tolerance: float | None,
179
+ ) -> tuple[
180
+ list[Vector2d],
181
+ list[tuple[int, int, int]],
182
+ list[list[Vector2d]] | None,
183
+ list[list[Vector2d]] | None,
184
+ ]:
185
+ if len(out_points) < 3:
186
+ raise _TriangulationError("外形の頂点数が3点未満です")
187
+
188
+ min_x: float = min(point.x for point in out_points)
189
+ max_x: float = max(point.x for point in out_points)
190
+ min_y: float = min(point.y for point in out_points)
191
+ max_y: float = max(point.y for point in out_points)
192
+ scale: float = max(max_x - min_x, max_y - min_y)
193
+ if scale <= 0.0 or not math.isfinite(scale):
194
+ raise _TriangulationError("外形の大きさが0です")
195
+
196
+ center_x: float = (min_x + max_x) / 2.0
197
+ center_y: float = (min_y + max_y) / 2.0
198
+
199
+ if merge_tolerance is None:
200
+ eps: float = 1.0e-10
201
+ else:
202
+ if merge_tolerance <= 0.0 or not math.isfinite(merge_tolerance):
203
+ raise ValueError("merge_toleranceは正の有限値で指定してください")
204
+ eps = max(merge_tolerance / scale, 1.0e-14)
205
+
206
+ def normalize(ring: list[Vector2d]) -> list[Vector2d]:
207
+ return [
208
+ Vector2d(
209
+ (point.x - center_x) / scale,
210
+ (point.y - center_y) / scale,
211
+ )
212
+ for point in ring
213
+ ]
214
+
215
+ outer: list[Vector2d] = _clean_ring(normalize(out_points), eps, "外形")
216
+ holes: list[list[Vector2d]] = [
217
+ _clean_ring(
218
+ normalize(ring),
219
+ eps,
220
+ f"開口{index}",
221
+ )
222
+ for index, ring in enumerate(in_points, start=1)
223
+ ]
224
+
225
+ # ほぼ同じY座標を共通値にする。
226
+ # 同じ高さの異なるリングの頂点が、誤差で薄い水平帯が生じることを防ぐ。
227
+ snapped: list[list[Vector2d]] = _snap_near_y([outer, *holes], eps)
228
+ outer = _clean_ring(snapped[0], eps, "外形")
229
+ holes = [
230
+ _clean_ring(ring, eps, f"開口{index}")
231
+ for index, ring in enumerate(snapped[1:], start=1)
232
+ ]
233
+
234
+ _validate_simple_ring(outer, eps, "外形")
235
+ for index, hole in enumerate(holes, start=1):
236
+ _validate_simple_ring(hole, eps, f"開口{index}")
237
+
238
+ normalized_vertices, faces = _triangulate(outer, holes, eps)
239
+ if faces:
240
+ boundary_loops = _extract_boundary_loops(
241
+ normalized_vertices,
242
+ faces,
243
+ eps,
244
+ )
245
+ output_outers, output_holes = _classify_boundary_loops(
246
+ boundary_loops,
247
+ eps,
248
+ )
249
+ else:
250
+ # 外形全体が開口の和集合で覆われた正常な空結果。
251
+ output_outers = []
252
+ output_holes = []
253
+
254
+ comparison_eps: float = max(eps * 8.0, 1.0e-12)
255
+ outer_changed: bool = not _ring_sets_equivalent(
256
+ [_simplify_loop(outer, comparison_eps)],
257
+ output_outers,
258
+ comparison_eps,
259
+ )
260
+ holes_changed = not _ring_sets_equivalent(
261
+ [_simplify_loop(hole, comparison_eps) for hole in holes],
262
+ output_holes,
263
+ comparison_eps,
264
+ )
265
+
266
+ def denormalize(point: Vector2d) -> Vector2d:
267
+ return Vector2d(
268
+ point.x * scale + center_x,
269
+ point.y * scale + center_y,
270
+ )
271
+
272
+ vertices: list[Vector2d] = [denormalize(point) for point in normalized_vertices]
273
+ new_outer: list[list[Vector2d]] | None = (
274
+ [[denormalize(point) for point in outer_loop] for outer_loop in output_outers]
275
+ if outer_changed
276
+ else None
277
+ )
278
+ new_holes: list[list[Vector2d]] | None = (
279
+ [[denormalize(point) for point in hole] for hole in output_holes]
280
+ if holes_changed
281
+ else None
282
+ )
283
+ return vertices, faces, new_outer, new_holes
284
+
285
+
286
+ def _clean_ring(ring: list[Vector2d], eps: float, label: str) -> list[Vector2d]:
287
+ if not ring:
288
+ raise _TriangulationError(f"{label}に頂点がありません")
289
+
290
+ points: list[Vector2d] = list(ring)
291
+ limit: float = eps * eps
292
+
293
+ if len(points) > 1 and _distance_2(points[0], points[-1]) <= limit:
294
+ points.pop()
295
+
296
+ cleaned: list[Vector2d] = []
297
+ for point in points:
298
+ if not cleaned or _distance_2(cleaned[-1], point) > limit:
299
+ cleaned.append(point)
300
+
301
+ if len(cleaned) > 1 and _distance_2(cleaned[0], cleaned[-1]) <= limit:
302
+ cleaned.pop()
303
+
304
+ if len(cleaned) < 3:
305
+ raise _TriangulationError(f"{label}の有効な頂点が3点未満です")
306
+
307
+ for _ in range(len(cleaned) + 2):
308
+ result: list[Vector2d] = []
309
+ changed: bool = False
310
+ count: int = len(cleaned)
311
+
312
+ for index, point in enumerate(cleaned):
313
+ previous: Vector2d = cleaned[(index - 1) % count]
314
+ following: Vector2d = cleaned[(index + 1) % count]
315
+ local_scale: float = (
316
+ previous.distance_to(point) + point.distance_to(following) + 1.0
317
+ )
318
+
319
+ if abs(_cross(previous, point, following)) <= eps * local_scale:
320
+ if _on_segment(point, previous, following, eps):
321
+ changed = True
322
+ continue
323
+ if _on_segment(previous, point, following, eps) or _on_segment(
324
+ following, previous, point, eps
325
+ ):
326
+ raise _TriangulationError(
327
+ f"{label}に折り返しまたは重複する辺があります"
328
+ )
329
+ result.append(point)
330
+
331
+ cleaned = result
332
+ if len(cleaned) < 3:
333
+ raise _TriangulationError(f"{label}の有効な頂点が3点未満です")
334
+ if not changed:
335
+ break
336
+
337
+ area: float = abs(_signed_area(cleaned))
338
+ if area <= max(1.0e-16, eps * _perimeter(cleaned) * 2.0):
339
+ raise _TriangulationError(f"{label}の面積が小さすぎます")
340
+ return cleaned
341
+
342
+
343
+ def _snap_near_y(rings: list[list[Vector2d]], eps: float) -> list[list[Vector2d]]:
344
+ """許容誤差内にある頂点Y座標を同じ値へまとめる"""
345
+ values: list[float] = sorted({point.y for ring in rings for point in ring})
346
+ if not values:
347
+ return rings
348
+
349
+ replacement: dict[float, float] = {}
350
+ cluster: list[float] = [values[0]]
351
+ cluster_start: float = values[0]
352
+
353
+ for value in values[1:]:
354
+ if value - cluster_start <= eps:
355
+ cluster.append(value)
356
+ continue
357
+
358
+ representative: float = math.fsum(cluster) / len(cluster)
359
+ replacement.update((item, representative) for item in cluster)
360
+ cluster = [value]
361
+ cluster_start = value
362
+
363
+ representative = math.fsum(cluster) / len(cluster)
364
+ replacement.update((item, representative) for item in cluster)
365
+
366
+ return [
367
+ [Vector2d(point.x, replacement[point.y]) for point in ring] for ring in rings
368
+ ]
369
+
370
+
371
+ def _validate_simple_ring(
372
+ ring: list[Vector2d],
373
+ eps: float,
374
+ label: str,
375
+ ) -> None:
376
+ """リングが自己交差せず、非隣接辺が近すぎないことを確認する。"""
377
+ edges: list[tuple[Vector2d, Vector2d]] = _edge_pairs(ring)
378
+ count: int = len(edges)
379
+
380
+ for first_index, first in enumerate(edges):
381
+ if _distance_2(*first) <= eps * eps:
382
+ raise _TriangulationError(f"{label}に短すぎる辺があります")
383
+
384
+ for second_index in range(first_index + 1, count):
385
+ # 隣接辺は共有端点を持つため、非隣接辺だけ検査する。
386
+ if (
387
+ second_index == (first_index + 1) % count
388
+ or first_index == (second_index + 1) % count
389
+ ):
390
+ continue
391
+
392
+ if _segments_too_close(*first, *edges[second_index], eps):
393
+ raise _TriangulationError(
394
+ f"{label}が自己交差しているか、非隣接辺が近すぎます"
395
+ )
396
+
397
+
398
+ def _triangulate(
399
+ outer: list[Vector2d],
400
+ holes: list[list[Vector2d]],
401
+ eps: float,
402
+ ) -> tuple[list[Vector2d], list[tuple[int, int, int]]]:
403
+ """水平帯ごとの区間差を台形分割し、三角形メッシュを生成する。"""
404
+ rings: list[list[Vector2d]] = [outer, *holes]
405
+ ring_edges: list[list[_Edge]] = [
406
+ _make_edges(ring, ring_index) for ring_index, ring in enumerate(rings)
407
+ ]
408
+ all_edges: list[_Edge] = [edge for edges in ring_edges for edge in edges]
409
+
410
+ # 外形辺と開口辺、または異なる開口辺が交差する高さでは、水平帯の境界を追加する。
411
+ # これにより、帯の途中で左右境界が入れ替わらない。
412
+ intersection_points: list[Vector2d] = _collect_cross_ring_intersections(
413
+ all_edges, eps
414
+ )
415
+ event_points: list[Vector2d] = [
416
+ point for ring in rings for point in ring
417
+ ] + intersection_points
418
+
419
+ levels: list[float] = _unique(
420
+ [point.y for point in event_points],
421
+ eps / 2.0,
422
+ )
423
+ if len(levels) < 2:
424
+ raise _TriangulationError("Y方向の広がりがありません")
425
+
426
+ events: dict[float, list[float]] = {level: [] for level in levels}
427
+ for point in event_points:
428
+ level: float = _nearest_value(levels, point.y)
429
+ if abs(level - point.y) <= eps * 2.0:
430
+ events[level].append(point.x)
431
+ events = {level: _unique(values, eps / 2.0) for level, values in events.items()}
432
+
433
+ pool: _VertexPool = _VertexPool(eps)
434
+ faces: list[tuple[int, int, int]] = []
435
+ expected_area: float = 0.0
436
+ area_eps: float = max(1.0e-16, eps * eps)
437
+
438
+ for y0, y1 in itertools.pairwise(levels):
439
+ if y1 - y0 <= eps:
440
+ continue
441
+
442
+ middle_y: float = (y0 + y1) / 2.0
443
+ outer_intervals: list[_BandInterval] = _ring_intervals_at_y(
444
+ ring_edges[0],
445
+ middle_y,
446
+ eps,
447
+ "外形",
448
+ )
449
+
450
+ opening_intervals: list[_BandInterval] = []
451
+ for index, edges in enumerate(ring_edges[1:], start=1):
452
+ opening_intervals.extend(
453
+ _ring_intervals_at_y(
454
+ edges,
455
+ middle_y,
456
+ eps,
457
+ f"開口{index}",
458
+ )
459
+ )
460
+
461
+ opening_union: list[_BandInterval] = _merge_intervals(opening_intervals, eps)
462
+ solid_intervals: list[_BandInterval] = []
463
+ for outer_interval in outer_intervals:
464
+ solid_intervals.extend(
465
+ _subtract_intervals(
466
+ outer_interval,
467
+ opening_union,
468
+ eps,
469
+ )
470
+ )
471
+
472
+ for interval in solid_intervals:
473
+ left0: float = interval.left_edge.intersection_x(y0, eps)
474
+ right0: float = interval.right_edge.intersection_x(y0, eps)
475
+ left1: float = interval.left_edge.intersection_x(y1, eps)
476
+ right1: float = interval.right_edge.intersection_x(y1, eps)
477
+
478
+ width0: float = right0 - left0
479
+ width1: float = right1 - left1
480
+ if width0 < -eps or width1 < -eps:
481
+ raise _TriangulationError(
482
+ "水平帯の途中で左右境界が逆転しました。"
483
+ "交点高さの計算に失敗している可能性があります"
484
+ )
485
+
486
+ if abs(width0) <= eps:
487
+ width0 = 0.0
488
+ if abs(width1) <= eps:
489
+ width1 = 0.0
490
+
491
+ cell_area: float = (width0 + width1) * (y1 - y0) / 2.0
492
+ if cell_area <= area_eps:
493
+ continue
494
+
495
+ expected_area += cell_area
496
+ _triangulate_cell(
497
+ pool,
498
+ faces,
499
+ y0,
500
+ y1,
501
+ left0,
502
+ right0,
503
+ left1,
504
+ right1,
505
+ events[y0],
506
+ events[y1],
507
+ eps,
508
+ area_eps,
509
+ )
510
+
511
+ if expected_area <= area_eps:
512
+ # 開口の和集合が外形全体を覆う場合は、空メッシュを正常結果とする。
513
+ return [], []
514
+ if not faces:
515
+ raise _TriangulationError("三角形を生成できませんでした")
516
+
517
+ actual_area: float = math.fsum(
518
+ abs(_cross(pool.points[a], pool.points[b], pool.points[c])) / 2.0
519
+ for a, b, c in faces
520
+ )
521
+ tolerance: float = max(expected_area * 1.0e-8, eps * 16.0, 1.0e-12)
522
+ if abs(actual_area - expected_area) > tolerance:
523
+ raise _TriangulationError(
524
+ "面積検証に失敗しました "
525
+ f"(水平帯={expected_area:.12g}, 三角形={actual_area:.12g})"
526
+ )
527
+
528
+ return pool.points, faces
529
+
530
+
531
+ def _make_edges(ring: list[Vector2d], ring_index: int) -> list[_Edge]:
532
+ return [
533
+ _Edge(a, b, ring_index, edge_index)
534
+ for edge_index, (a, b) in enumerate(_edge_pairs(ring))
535
+ ]
536
+
537
+
538
+ def _collect_cross_ring_intersections(
539
+ edges: list[_Edge],
540
+ eps: float,
541
+ ) -> list[Vector2d]:
542
+ result: list[Vector2d] = []
543
+
544
+ for first_index, first in enumerate(edges):
545
+ for second in edges[first_index + 1 :]:
546
+ if first.ring_index == second.ring_index:
547
+ continue
548
+ result.extend(
549
+ _segment_intersection_points(
550
+ first.a,
551
+ first.b,
552
+ second.a,
553
+ second.b,
554
+ eps,
555
+ )
556
+ )
557
+
558
+ return _unique_points(result, eps)
559
+
560
+
561
+ def _ring_intervals_at_y(
562
+ edges: list[_Edge],
563
+ y: float,
564
+ eps: float,
565
+ label: str,
566
+ ) -> list[_BandInterval]:
567
+ """単純リングが水平線上で占めるX区間を返す。"""
568
+ crossings: list[tuple[float, _Edge]] = sorted(
569
+ (
570
+ (edge.intersection_x(y, eps), edge)
571
+ for edge in edges
572
+ if edge.is_intersecting(y)
573
+ ),
574
+ key=lambda item: (
575
+ item[0],
576
+ item[1].ring_index,
577
+ item[1].edge_index,
578
+ ),
579
+ )
580
+
581
+ if len(crossings) % 2:
582
+ raise _TriangulationError(f"高さ{y:g}で{label}の境界交点数が奇数です")
583
+
584
+ result: list[_BandInterval] = []
585
+ for index in range(0, len(crossings), 2):
586
+ left_x, left_edge = crossings[index]
587
+ right_x, right_edge = crossings[index + 1]
588
+ if right_x - left_x <= eps:
589
+ raise _TriangulationError(f"高さ{y:g}で{label}の幅が許容誤差以下です")
590
+ result.append(
591
+ _BandInterval(
592
+ left_edge,
593
+ right_edge,
594
+ left_x,
595
+ right_x,
596
+ )
597
+ )
598
+ return result
599
+
600
+
601
+ def _merge_intervals(
602
+ intervals: list[_BandInterval],
603
+ eps: float,
604
+ ) -> list[_BandInterval]:
605
+ """開口区間を重なりと接触を含めて和集合へまとめる。"""
606
+ if not intervals:
607
+ return []
608
+
609
+ ordered: list[_BandInterval] = sorted(
610
+ intervals,
611
+ key=lambda interval: (
612
+ interval.left_x,
613
+ interval.right_x,
614
+ interval.left_edge.ring_index,
615
+ interval.left_edge.edge_index,
616
+ ),
617
+ )
618
+ merged: list[_BandInterval] = [ordered[0]]
619
+
620
+ for interval in ordered[1:]:
621
+ current: _BandInterval = merged[-1]
622
+ if interval.left_x > current.right_x + eps:
623
+ merged.append(interval)
624
+ continue
625
+
626
+ # 区間が重なる場合、左端は並び順の先頭を維持し、
627
+ # 右端だけをより遠くまで伸びる境界へ更新する。
628
+ if interval.right_x > current.right_x + eps:
629
+ merged[-1] = _BandInterval(
630
+ current.left_edge,
631
+ interval.right_edge,
632
+ current.left_x,
633
+ interval.right_x,
634
+ )
635
+
636
+ return merged
637
+
638
+
639
+ def _subtract_intervals(
640
+ outer: _BandInterval,
641
+ openings: list[_BandInterval],
642
+ eps: float,
643
+ ) -> list[_BandInterval]:
644
+ """一つの外形区間から開口区間の和集合を差し引く。"""
645
+ result: list[_BandInterval] = []
646
+ cursor_x: float = outer.left_x
647
+ cursor_edge: _Edge = outer.left_edge
648
+ outer_right: float = outer.right_x
649
+
650
+ for opening in openings:
651
+ if opening.right_x <= cursor_x + eps:
652
+ continue
653
+ if opening.left_x >= outer_right - eps:
654
+ break
655
+
656
+ if opening.left_x > cursor_x + eps:
657
+ segment_right: float = min(opening.left_x, outer_right)
658
+ segment_right_edge: _Edge = (
659
+ opening.left_edge
660
+ if opening.left_x < outer_right - eps
661
+ else outer.right_edge
662
+ )
663
+ if segment_right - cursor_x > eps:
664
+ result.append(
665
+ _BandInterval(
666
+ cursor_edge,
667
+ segment_right_edge,
668
+ cursor_x,
669
+ segment_right,
670
+ )
671
+ )
672
+
673
+ if opening.right_x >= outer_right - eps:
674
+ cursor_x = outer_right
675
+ cursor_edge = outer.right_edge
676
+ break
677
+
678
+ if opening.right_x > cursor_x + eps:
679
+ cursor_x = opening.right_x
680
+ cursor_edge = opening.right_edge
681
+
682
+ if outer_right - cursor_x > eps:
683
+ result.append(
684
+ _BandInterval(
685
+ cursor_edge,
686
+ outer.right_edge,
687
+ cursor_x,
688
+ outer_right,
689
+ )
690
+ )
691
+
692
+ return result
693
+
694
+
695
+ def _triangulate_cell(
696
+ pool: _VertexPool,
697
+ faces: list[tuple[int, int, int]],
698
+ y0: float,
699
+ y1: float,
700
+ left0: float,
701
+ right0: float,
702
+ left1: float,
703
+ right1: float,
704
+ events0: list[float],
705
+ events1: list[float],
706
+ eps: float,
707
+ area_eps: float,
708
+ ) -> None:
709
+ """一つの台形を、境界イベントを共有する三角形へ分割する。"""
710
+ width0: float = right0 - left0
711
+ width1: float = right1 - left1
712
+
713
+ if width0 < -eps or width1 < -eps:
714
+ raise _TriangulationError("水平帯の左右境界が逆転しました")
715
+
716
+ if abs(width0) <= eps:
717
+ left0 = right0 = (left0 + right0) / 2.0
718
+ width0 = 0.0
719
+ if abs(width1) <= eps:
720
+ left1 = right1 = (left1 + right1) / 2.0
721
+ width1 = 0.0
722
+ if width0 == 0.0 and width1 == 0.0:
723
+ return
724
+
725
+ bottom: list[Vector2d] = _chain(y0, left0, right0, events0, eps)
726
+ top: list[Vector2d] = _chain(y1, left1, right1, events1, eps)
727
+
728
+ if width0 == 0.0:
729
+ for index in range(len(top) - 1):
730
+ _add_triangle(
731
+ pool,
732
+ faces,
733
+ bottom[0],
734
+ top[index + 1],
735
+ top[index],
736
+ area_eps,
737
+ )
738
+ return
739
+
740
+ if width1 == 0.0:
741
+ for index in range(len(bottom) - 1):
742
+ _add_triangle(
743
+ pool,
744
+ faces,
745
+ top[0],
746
+ bottom[index],
747
+ bottom[index + 1],
748
+ area_eps,
749
+ )
750
+ return
751
+
752
+ # 凸セルを左上から右下へ向かう対角線で二つの扇形に分ける。
753
+ for index in range(len(bottom) - 1):
754
+ _add_triangle(
755
+ pool,
756
+ faces,
757
+ top[0],
758
+ bottom[index],
759
+ bottom[index + 1],
760
+ area_eps,
761
+ )
762
+ for index in range(len(top) - 1):
763
+ _add_triangle(
764
+ pool,
765
+ faces,
766
+ bottom[-1],
767
+ top[index + 1],
768
+ top[index],
769
+ area_eps,
770
+ )
771
+
772
+
773
+ def _chain(
774
+ y: float,
775
+ first: float,
776
+ last: float,
777
+ events: list[float],
778
+ eps: float,
779
+ ) -> list[Vector2d]:
780
+ """水平セル辺を、同じ高さにある境界イベント位置で分割する。"""
781
+ if last < first:
782
+ first, last = last, first
783
+
784
+ values: list[float] = [first, last]
785
+ values.extend(x for x in events if first + eps < x < last - eps)
786
+ return [Vector2d(x, y) for x in _unique(values, eps / 2.0)]
787
+
788
+
789
+ def _add_triangle(
790
+ pool: _VertexPool,
791
+ faces: list[tuple[int, int, int]],
792
+ a: Vector2d,
793
+ b: Vector2d,
794
+ c: Vector2d,
795
+ area_eps: float,
796
+ ) -> None:
797
+ area2: float = _cross(a, b, c)
798
+ if abs(area2) <= 2.0 * area_eps:
799
+ return
800
+ if area2 < 0.0:
801
+ b, c = c, b
802
+
803
+ indices: tuple[int, int, int] = (pool.add(a), pool.add(b), pool.add(c))
804
+ if len(set(indices)) == 3:
805
+ faces.append(indices)
806
+
807
+
808
+ def _extract_boundary_loops(
809
+ points: list[Vector2d],
810
+ faces: list[tuple[int, int, int]],
811
+ eps: float,
812
+ ) -> list[list[Vector2d]]:
813
+ """三角形の境界半辺をたどり、外周と内周のループを復元する。
814
+
815
+ 頂点だけで接触する複数開口では、一つの頂点に複数の境界が集まる。
816
+ 単純な頂点隣接表では経路を一意に決められないため、三角形の隣接面を回りながら次の境界半辺を探す。
817
+ これにより接触点でも各ループを分離する。
818
+ """
819
+ owner: dict[tuple[int, int], tuple[int, int]] = {}
820
+ face_edges: list[tuple[tuple[int, int], tuple[int, int], tuple[int, int]]] = []
821
+ undirected_count: dict[tuple[int, ...], int] = {}
822
+
823
+ for face_index, (a, b, c) in enumerate(faces):
824
+ edges: tuple[tuple[int, int], tuple[int, int], tuple[int, int]] = (
825
+ (a, b),
826
+ (b, c),
827
+ (c, a),
828
+ )
829
+ face_edges.append(edges)
830
+
831
+ for local_index, edge in enumerate(edges):
832
+ if edge in owner:
833
+ raise _TriangulationError(
834
+ "同じ向きの半辺を複数の三角形が共有しています"
835
+ )
836
+ owner[edge] = (face_index, local_index)
837
+ key: tuple[int, ...] = tuple(sorted(edge))
838
+ undirected_count[key] = undirected_count.get(key, 0) + 1
839
+
840
+ if any(count > 2 for count in undirected_count.values()):
841
+ raise _TriangulationError("三角形メッシュに非多様体辺があります")
842
+
843
+ boundary_edges: set[tuple[int, int]] = {
844
+ edge for edge in owner if (edge[1], edge[0]) not in owner
845
+ }
846
+ if not boundary_edges:
847
+ raise _TriangulationError("メッシュ境界を復元できませんでした")
848
+
849
+ next_boundary: dict[tuple[int, int], tuple[int, int]] = {}
850
+ maximum_steps: int = len(owner) + 1
851
+
852
+ for start_edge in boundary_edges:
853
+ current: tuple[int, int] = start_edge
854
+ for _ in range(maximum_steps):
855
+ face_index, local_index = owner[current]
856
+ candidate: tuple[int, int] = face_edges[face_index][(local_index + 1) % 3]
857
+ reverse: tuple[int, int] = (candidate[1], candidate[0])
858
+
859
+ if reverse not in owner:
860
+ if candidate not in boundary_edges:
861
+ raise _TriangulationError("境界半辺の隣接関係が矛盾しています")
862
+ next_boundary[start_edge] = candidate
863
+ break
864
+
865
+ # 内部辺を反対側の三角形へ渡り、同じ頂点の周囲を回る。
866
+ current = reverse
867
+ else:
868
+ raise _TriangulationError("境界半辺の次辺探索が収束しませんでした")
869
+
870
+ loops: list[list[Vector2d]] = []
871
+ used: set[tuple[int, int]] = set()
872
+
873
+ for start_edge in sorted(boundary_edges):
874
+ if start_edge in used:
875
+ continue
876
+
877
+ loop_indices: list[int] = []
878
+ current = start_edge
879
+
880
+ for _ in range(len(boundary_edges) + 1):
881
+ if current in used:
882
+ if current != start_edge:
883
+ raise _TriangulationError(
884
+ "境界ループが別のループへ途中接続しています"
885
+ )
886
+ break
887
+
888
+ used.add(current)
889
+ loop_indices.append(current[0])
890
+ current = next_boundary[current]
891
+
892
+ if current == start_edge:
893
+ break
894
+ else:
895
+ raise _TriangulationError("境界ループの追跡が収束しませんでした")
896
+
897
+ if current != start_edge:
898
+ raise _TriangulationError("閉じていないメッシュ境界があります")
899
+
900
+ # 点接触する複数境界は、一つの閉路内で同じ頂点を複数回通る場合がある。
901
+ # その閉路を共有頂点ごとの単純ループへ分解する。
902
+ for simple_indices in _split_repeated_vertex_walk(loop_indices):
903
+ loop = _simplify_loop(
904
+ [points[index] for index in simple_indices],
905
+ eps * 4.0,
906
+ )
907
+ if len(loop) < 3:
908
+ raise _TriangulationError("境界ループの頂点が3点未満です")
909
+ loops.append(loop)
910
+
911
+ if used != boundary_edges:
912
+ raise _TriangulationError("一部の境界半辺をループへ復元できませんでした")
913
+ return loops
914
+
915
+
916
+ def _split_repeated_vertex_walk(
917
+ walk: list[int],
918
+ ) -> list[list[int]]:
919
+ """同じ頂点を複数回通る閉路を、単純な閉路へ再帰的に分解する。
920
+
921
+ 開口同士が一点で接触する場合、境界半辺の追跡結果が8の字状の一つの閉路になることがある。
922
+ 共有頂点の二つの出現位置で閉路を切り分けると、元の二つの単純な境界ループを復元できる。
923
+ """
924
+ pending: list[list[int]] = [walk]
925
+ result: list[list[int]] = []
926
+
927
+ while pending:
928
+ current = pending.pop()
929
+ first_position: dict[int, int] = {}
930
+ split_pair: tuple[int, int] | None = None
931
+
932
+ for index, vertex in enumerate(current):
933
+ previous = first_position.get(vertex)
934
+ if previous is not None:
935
+ split_pair = (previous, index)
936
+ break
937
+ first_position[vertex] = index
938
+
939
+ if split_pair is None:
940
+ result.append(current)
941
+ continue
942
+
943
+ first, second = split_pair
944
+ first_loop = current[first:second]
945
+ second_loop = current[:first] + current[second:]
946
+
947
+ if len(first_loop) < 3 or len(second_loop) < 3:
948
+ raise _TriangulationError("共有頂点で分解した境界ループの頂点が3点未満です")
949
+
950
+ pending.append(second_loop)
951
+ pending.append(first_loop)
952
+
953
+ return result
954
+
955
+
956
+ def _classify_boundary_loops(
957
+ loops: list[list[Vector2d]],
958
+ eps: float,
959
+ ) -> tuple[list[list[Vector2d]], list[list[Vector2d]]]:
960
+ """符号付き面積で外周と内周を分類し、向きと順序を正規化する。
961
+
962
+ 三角形面は反時計回りで生成されるため、材料領域を左側に見てたどる
963
+ 境界の外周は反時計回り、内周は時計回りになる。
964
+ 開口が外形を横断して材料領域が分断された場合は、複数の外周ループをそのまま返す。
965
+ """
966
+ area_tolerance: float = max(eps * eps * 4.0, 1.0e-16)
967
+ outer_loops: list[list[Vector2d]] = []
968
+ hole_loops: list[list[Vector2d]] = []
969
+
970
+ for loop in loops:
971
+ area: float = _signed_area(loop)
972
+ if area > area_tolerance:
973
+ outer_loops.append(_rotate_loop_to_stable_start(loop))
974
+ elif area < -area_tolerance:
975
+ hole_loops.append(_rotate_loop_to_stable_start(loop))
976
+ else:
977
+ raise _TriangulationError("面積が小さすぎる境界ループがあります")
978
+
979
+ if not outer_loops:
980
+ raise _TriangulationError("処理後の外形ループを取得できませんでした")
981
+
982
+ outer_loops.sort(key=_loop_sort_key)
983
+ hole_loops.sort(key=_loop_sort_key)
984
+ return outer_loops, hole_loops
985
+
986
+
987
+ def _simplify_loop(loop: list[Vector2d], eps: float) -> list[Vector2d]:
988
+ """境界ループから連続重複点と一直線上の中間点を除去する。"""
989
+ if not loop:
990
+ return []
991
+
992
+ limit: float = eps * eps
993
+ cleaned: list[Vector2d] = []
994
+ for point in loop:
995
+ if not cleaned or _distance_2(cleaned[-1], point) > limit:
996
+ cleaned.append(point)
997
+
998
+ if len(cleaned) > 1 and _distance_2(cleaned[0], cleaned[-1]) <= limit:
999
+ cleaned.pop()
1000
+
1001
+ for _ in range(len(cleaned) + 2):
1002
+ if len(cleaned) < 3:
1003
+ break
1004
+
1005
+ result: list[Vector2d] = []
1006
+ changed: bool = False
1007
+ count: int = len(cleaned)
1008
+
1009
+ for index, point in enumerate(cleaned):
1010
+ previous: Vector2d = cleaned[(index - 1) % count]
1011
+ following: Vector2d = cleaned[(index + 1) % count]
1012
+ local_scale: float = (
1013
+ previous.distance_to(point) + point.distance_to(following) + 1.0
1014
+ )
1015
+ if abs(
1016
+ _cross(previous, point, following)
1017
+ ) <= eps * local_scale and _on_segment(point, previous, following, eps):
1018
+ changed = True
1019
+ continue
1020
+ result.append(point)
1021
+
1022
+ cleaned = result
1023
+ if not changed:
1024
+ break
1025
+
1026
+ return cleaned
1027
+
1028
+
1029
+ def _rings_equivalent(
1030
+ first: list[Vector2d],
1031
+ second: list[Vector2d],
1032
+ eps: float,
1033
+ ) -> bool:
1034
+ if len(first) != len(second):
1035
+ return False
1036
+ if not first:
1037
+ return True
1038
+
1039
+ limit: float = eps * eps
1040
+ candidates: list[int] = [
1041
+ index
1042
+ for index, point in enumerate(second)
1043
+ if _distance_2(first[0], point) <= limit
1044
+ ]
1045
+
1046
+ for start in candidates:
1047
+ forward: bool = all(
1048
+ _distance_2(first[index], second[(start + index) % len(second)]) <= limit
1049
+ for index in range(len(first))
1050
+ )
1051
+ if forward:
1052
+ return True
1053
+
1054
+ backward: bool = all(
1055
+ _distance_2(first[index], second[(start - index) % len(second)]) <= limit
1056
+ for index in range(len(first))
1057
+ )
1058
+ if backward:
1059
+ return True
1060
+
1061
+ return False
1062
+
1063
+
1064
+ def _ring_sets_equivalent(
1065
+ first: list[list[Vector2d]],
1066
+ second: list[list[Vector2d]],
1067
+ eps: float,
1068
+ ) -> bool:
1069
+ if len(first) != len(second):
1070
+ return False
1071
+
1072
+ unmatched: list[int] = list(range(len(second)))
1073
+ for first_ring in first:
1074
+ matched_index: int | None = None
1075
+ for second_index in unmatched:
1076
+ if _rings_equivalent(first_ring, second[second_index], eps):
1077
+ matched_index = second_index
1078
+ break
1079
+ if matched_index is None:
1080
+ return False
1081
+ unmatched.remove(matched_index)
1082
+
1083
+ return not unmatched
1084
+
1085
+
1086
+ def _rotate_loop_to_stable_start(loop: list[Vector2d]) -> list[Vector2d]:
1087
+ """最小のY、次に最小のXとなる頂点をリング先頭へ移動する。"""
1088
+ if not loop:
1089
+ return []
1090
+ start: int = min(
1091
+ range(len(loop)),
1092
+ key=lambda index: (loop[index].y, loop[index].x),
1093
+ )
1094
+ return loop[start:] + loop[:start]
1095
+
1096
+
1097
+ def _loop_sort_key(loop: list[Vector2d]) -> tuple[float, float, float, int]:
1098
+ """開口リングを決定的な順序へ並べるためのキー"""
1099
+ min_y = min(point.y for point in loop)
1100
+ min_x = min(point.x for point in loop)
1101
+ return (min_y, min_x, -abs(_signed_area(loop)), len(loop))
1102
+
1103
+
1104
+ def _segment_intersection_points(
1105
+ a: Vector2d,
1106
+ b: Vector2d,
1107
+ c: Vector2d,
1108
+ d: Vector2d,
1109
+ eps: float,
1110
+ ) -> list[Vector2d]:
1111
+ """2線分の交点。共線重複時は重複区間の端点。"""
1112
+ if not _segments_intersect(a, b, c, d, eps):
1113
+ return []
1114
+
1115
+ rx: float = b.x - a.x
1116
+ ry: float = b.y - a.y
1117
+ sx: float = d.x - c.x
1118
+ sy: float = d.y - c.y
1119
+ denominator: float = rx * sy - ry * sx
1120
+ scale: float = math.hypot(rx, ry) * math.hypot(sx, sy) + 1.0
1121
+
1122
+ if abs(denominator) > eps * scale:
1123
+ qpx: float = c.x - a.x
1124
+ qpy: float = c.y - a.y
1125
+ t: float = (qpx * sy - qpy * sx) / denominator
1126
+ u: float = (qpx * ry - qpy * rx) / denominator
1127
+
1128
+ parameter_tolerance: float = eps / max(
1129
+ math.hypot(rx, ry),
1130
+ math.hypot(sx, sy),
1131
+ eps,
1132
+ )
1133
+ if (
1134
+ -parameter_tolerance <= t <= 1.0 + parameter_tolerance
1135
+ and -parameter_tolerance <= u <= 1.0 + parameter_tolerance
1136
+ ):
1137
+ t = min(1.0, max(0.0, t))
1138
+ u = min(1.0, max(0.0, u))
1139
+ first_point: Vector2d = Vector2d(a.x + t * rx, a.y + t * ry)
1140
+ second_point: Vector2d = Vector2d(c.x + u * sx, c.y + u * sy)
1141
+ return [
1142
+ Vector2d(
1143
+ (first_point.x + second_point.x) / 2.0,
1144
+ (first_point.y + second_point.y) / 2.0,
1145
+ )
1146
+ ]
1147
+ return []
1148
+
1149
+ # 平行線分の場合は、両方の線分上にある端点を集める。
1150
+ candidates: list[Vector2d] = [
1151
+ point
1152
+ for point in (a, b, c, d)
1153
+ if _on_segment(point, a, b, eps) and _on_segment(point, c, d, eps)
1154
+ ]
1155
+ return _unique_points(candidates, eps)
1156
+
1157
+
1158
+ def _unique_points(points: list[Vector2d], eps: float) -> list[Vector2d]:
1159
+ """許容誤差内で一致する点を一つへまとめる。"""
1160
+ result: list[Vector2d] = []
1161
+ limit: float = eps * eps
1162
+
1163
+ for point in sorted(points, key=lambda item: (item.y, item.x)):
1164
+ if any(_distance_2(point, existing) <= limit for existing in result):
1165
+ continue
1166
+ result.append(point)
1167
+ return result
1168
+
1169
+
1170
+ def _nearest_value(values: list[float], target: float) -> float:
1171
+ return min(values, key=lambda value: abs(value - target))
1172
+
1173
+
1174
+ def _unique(values: list[float], eps: float) -> list[float]:
1175
+ """近接する数値を平均値へまとめ、昇順で返す。"""
1176
+ result: list[float] = []
1177
+ for value in sorted(values):
1178
+ if not result or value - result[-1] > eps:
1179
+ result.append(value)
1180
+ else:
1181
+ result[-1] = (result[-1] + value) / 2.0
1182
+ return result
1183
+
1184
+
1185
+ def _edge_pairs(ring: list[Vector2d]) -> list[tuple[Vector2d, Vector2d]]:
1186
+ return list(zip(ring, ring[1:] + ring[:1]))
1187
+
1188
+
1189
+ def _distance_2(a: Vector2d, b: Vector2d) -> float:
1190
+ return (a.x - b.x) ** 2 + (a.y - b.y) ** 2
1191
+
1192
+
1193
+ def _cross(a: Vector2d, b: Vector2d, c: Vector2d) -> float:
1194
+ return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
1195
+
1196
+
1197
+ def _signed_area(ring: list[Vector2d]) -> float:
1198
+ return math.fsum(a.x * b.y - b.x * a.y for a, b in _edge_pairs(ring)) / 2.0
1199
+
1200
+
1201
+ def _perimeter(ring: list[Vector2d]) -> float:
1202
+ return math.fsum(a.distance_to(b) for a, b in _edge_pairs(ring))
1203
+
1204
+
1205
+ def _point_segment_distance_2(
1206
+ point: Vector2d,
1207
+ a: Vector2d,
1208
+ b: Vector2d,
1209
+ ) -> float:
1210
+ dx: float = b.x - a.x
1211
+ dy: float = b.y - a.y
1212
+ length2: float = dx * dx + dy * dy
1213
+
1214
+ if length2 == 0.0:
1215
+ return _distance_2(point, a)
1216
+
1217
+ t: float = ((point.x - a.x) * dx + (point.y - a.y) * dy) / length2
1218
+ t = min(1.0, max(0.0, t))
1219
+ nearest: Vector2d = Vector2d(a.x + t * dx, a.y + t * dy)
1220
+ return _distance_2(point, nearest)
1221
+
1222
+
1223
+ def _on_segment(
1224
+ point: Vector2d,
1225
+ a: Vector2d,
1226
+ b: Vector2d,
1227
+ eps: float,
1228
+ ) -> bool:
1229
+ """点が許容誤差内で線分上にあるかの判定"""
1230
+ return (
1231
+ min(a.x, b.x) - eps <= point.x <= max(a.x, b.x) + eps
1232
+ and min(a.y, b.y) - eps <= point.y <= max(a.y, b.y) + eps
1233
+ and _point_segment_distance_2(point, a, b) <= eps * eps
1234
+ )
1235
+
1236
+
1237
+ def _orientation(
1238
+ a: Vector2d,
1239
+ b: Vector2d,
1240
+ c: Vector2d,
1241
+ eps: float,
1242
+ ) -> int:
1243
+ """3点の向きを反時計回り1、時計回り-1、共線0で返す。"""
1244
+ value: float = _cross(a, b, c)
1245
+ tolerance: float = eps * (a.distance_to(b) + a.distance_to(c) + 1.0)
1246
+ if value > tolerance:
1247
+ return 1
1248
+ if value < -tolerance:
1249
+ return -1
1250
+ return 0
1251
+
1252
+
1253
+ def _segments_intersect(
1254
+ a: Vector2d,
1255
+ b: Vector2d,
1256
+ c: Vector2d,
1257
+ d: Vector2d,
1258
+ eps: float,
1259
+ ) -> bool:
1260
+ """2線分が許容誤差内で交差または接触するかの判定"""
1261
+ if (
1262
+ max(a.x, b.x) + eps < min(c.x, d.x)
1263
+ or max(c.x, d.x) + eps < min(a.x, b.x)
1264
+ or max(a.y, b.y) + eps < min(c.y, d.y)
1265
+ or max(c.y, d.y) + eps < min(a.y, b.y)
1266
+ ):
1267
+ return False
1268
+
1269
+ o1: int = _orientation(a, b, c, eps)
1270
+ o2: int = _orientation(a, b, d, eps)
1271
+ o3: int = _orientation(c, d, a, eps)
1272
+ o4: int = _orientation(c, d, b, eps)
1273
+
1274
+ if o1 * o2 < 0 and o3 * o4 < 0:
1275
+ return True
1276
+
1277
+ return (
1278
+ (o1 == 0 and _on_segment(c, a, b, eps))
1279
+ or (o2 == 0 and _on_segment(d, a, b, eps))
1280
+ or (o3 == 0 and _on_segment(a, c, d, eps))
1281
+ or (o4 == 0 and _on_segment(b, c, d, eps))
1282
+ )
1283
+
1284
+
1285
+ def _segments_too_close(
1286
+ a: Vector2d,
1287
+ b: Vector2d,
1288
+ c: Vector2d,
1289
+ d: Vector2d,
1290
+ eps: float,
1291
+ ) -> bool:
1292
+ """2線分が交差するか、許容距離以内まで近接するかの判定"""
1293
+ if _segments_intersect(a, b, c, d, eps):
1294
+ return True
1295
+
1296
+ limit: float = eps * eps
1297
+ return (
1298
+ min(
1299
+ _point_segment_distance_2(a, c, d),
1300
+ _point_segment_distance_2(b, c, d),
1301
+ _point_segment_distance_2(c, a, b),
1302
+ _point_segment_distance_2(d, a, b),
1303
+ )
1304
+ <= limit
1305
+ )