dcel-map-generator 0.2.2__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.
@@ -0,0 +1,130 @@
1
+ """Tree-first recursive DCEL generation from a hierarchical zone tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+ from pathlib import Path
7
+
8
+ from dcel_builder.frontend_bundle import build_frontend_bundle
9
+ from dcel_builder.hierarchy import build_leaf_dcel_from_tree
10
+ from dcel_builder.tree_loader import load_tree_inputs
11
+
12
+
13
+ def _package_version() -> str:
14
+ try:
15
+ return version("dcel-map-generator")
16
+ except PackageNotFoundError:
17
+ return "0.2.2"
18
+
19
+
20
+ __version__ = _package_version()
21
+
22
+
23
+ def generate_map_artifacts(
24
+ zone_edges_path: str | Path,
25
+ tree_stats_path: str | Path,
26
+ zone_index_path: str | Path,
27
+ seed: int | None = None,
28
+ resolution: int = 512,
29
+ land_fraction: float = 0.40,
30
+ noise_exponent: float = 2.3,
31
+ warp_strength: float = 0.10,
32
+ quiet: bool = False,
33
+ blob_radius: float = 0.5,
34
+ disk_radius: int | None = None,
35
+ area_floor: float = 0.5,
36
+ ) -> tuple:
37
+ """Build the core map artifacts from a rooted zone tree."""
38
+ del blob_radius
39
+ del disk_radius
40
+ del area_floor
41
+
42
+ tree, tree_stats, zone_index = load_tree_inputs(
43
+ zone_edges_path,
44
+ tree_stats_path,
45
+ zone_index_path,
46
+ )
47
+ result = build_leaf_dcel_from_tree(
48
+ tree=tree,
49
+ seed=seed,
50
+ resolution=resolution,
51
+ land_fraction=land_fraction,
52
+ noise_exponent=noise_exponent,
53
+ warp_strength=warp_strength,
54
+ )
55
+ dcel, report = result.dcel, result.report
56
+ report["tree_stats_loaded"] = bool(tree_stats)
57
+ report["zone_index_loaded"] = bool(zone_index)
58
+
59
+ if not quiet:
60
+ interior = sum(1 for face in dcel.faces if not face.is_outer)
61
+ print(
62
+ f"Built recursive tree-first DCEL with {interior} leaf faces and "
63
+ f"{len(dcel.halfedges)} halfedges."
64
+ )
65
+
66
+ return dcel, report, tree, zone_index
67
+
68
+
69
+ def generate_dcel(
70
+ zone_edges_path: str | Path,
71
+ tree_stats_path: str | Path,
72
+ zone_index_path: str | Path,
73
+ seed: int | None = None,
74
+ resolution: int = 512,
75
+ land_fraction: float = 0.40,
76
+ noise_exponent: float = 2.3,
77
+ warp_strength: float = 0.10,
78
+ quiet: bool = False,
79
+ blob_radius: float = 0.5,
80
+ disk_radius: int | None = None,
81
+ area_floor: float = 0.5,
82
+ ) -> tuple:
83
+ """Build a leaf-level DCEL from a rooted zone tree."""
84
+ dcel, report, _, _ = generate_map_artifacts(
85
+ zone_edges_path=zone_edges_path,
86
+ tree_stats_path=tree_stats_path,
87
+ zone_index_path=zone_index_path,
88
+ seed=seed,
89
+ resolution=resolution,
90
+ land_fraction=land_fraction,
91
+ noise_exponent=noise_exponent,
92
+ warp_strength=warp_strength,
93
+ quiet=quiet,
94
+ blob_radius=blob_radius,
95
+ disk_radius=disk_radius,
96
+ area_floor=area_floor,
97
+ )
98
+ return dcel, report
99
+
100
+
101
+ def generate_frontend_bundle(
102
+ zone_edges_path: str | Path,
103
+ tree_stats_path: str | Path,
104
+ zone_index_path: str | Path,
105
+ seed: int | None = None,
106
+ resolution: int = 512,
107
+ land_fraction: float = 0.40,
108
+ noise_exponent: float = 2.3,
109
+ warp_strength: float = 0.10,
110
+ quiet: bool = False,
111
+ blob_radius: float = 0.5,
112
+ disk_radius: int | None = None,
113
+ area_floor: float = 0.5,
114
+ ) -> tuple[dict, dict]:
115
+ """Build a frontend-ready hierarchy bundle from a rooted zone tree."""
116
+ dcel, report, tree, zone_index = generate_map_artifacts(
117
+ zone_edges_path=zone_edges_path,
118
+ tree_stats_path=tree_stats_path,
119
+ zone_index_path=zone_index_path,
120
+ seed=seed,
121
+ resolution=resolution,
122
+ land_fraction=land_fraction,
123
+ noise_exponent=noise_exponent,
124
+ warp_strength=warp_strength,
125
+ quiet=quiet,
126
+ blob_radius=blob_radius,
127
+ disk_radius=disk_radius,
128
+ area_floor=area_floor,
129
+ )
130
+ return build_frontend_bundle(dcel, tree, zone_index), report
@@ -0,0 +1,212 @@
1
+ """CLI entry point for dcel_builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="Generate a DCEL map from a zone tree.")
13
+ parser.add_argument(
14
+ "--zone-edges",
15
+ default="examples/atlantis/zone_edges.json",
16
+ help="Path to zone tree edge-list JSON",
17
+ )
18
+ parser.add_argument(
19
+ "--leaf-graph",
20
+ default=None,
21
+ help="Deprecated compatibility alias for --zone-edges",
22
+ )
23
+ parser.add_argument(
24
+ "--tree-stats",
25
+ default="examples/atlantis/zone_tree_stats.json",
26
+ help="Path to zone tree stats JSON",
27
+ )
28
+ parser.add_argument(
29
+ "--zone-index",
30
+ default="examples/atlantis/zone_index.json",
31
+ help="Path to zone index JSON",
32
+ )
33
+ parser.add_argument(
34
+ "--output",
35
+ default="dcel_map.json",
36
+ help="Path for the output DCEL JSON",
37
+ )
38
+ parser.add_argument(
39
+ "--frontend-bundle",
40
+ default=None,
41
+ help="Optional path for a frontend-ready hierarchy JSON bundle",
42
+ )
43
+ parser.add_argument(
44
+ "--seed",
45
+ type=int,
46
+ default=None,
47
+ help="RNG seed for reproducible generation (random if omitted)",
48
+ )
49
+ parser.add_argument(
50
+ "--resolution",
51
+ type=int,
52
+ default=512,
53
+ help="Raster grid size H×H (default: 512)",
54
+ )
55
+ parser.add_argument(
56
+ "--land-fraction",
57
+ type=float,
58
+ default=0.40,
59
+ help="Target fraction of raster pixels that are land (default: 0.40)",
60
+ )
61
+ parser.add_argument(
62
+ "--noise-exponent",
63
+ type=float,
64
+ default=2.3,
65
+ help="Power-law exponent for spectral noise (default: 2.3)",
66
+ )
67
+ parser.add_argument(
68
+ "--warp-strength",
69
+ type=float,
70
+ default=0.10,
71
+ help="Domain-warp amplitude as fraction of resolution (default: 0.10)",
72
+ )
73
+ parser.add_argument(
74
+ "--canvas-size",
75
+ type=float,
76
+ default=1.0,
77
+ help="(Retained for compatibility; has no effect on raster resolution)",
78
+ )
79
+ parser.add_argument(
80
+ "--blob-radius",
81
+ type=float,
82
+ default=0.5,
83
+ help="Gaussian blob radius for continent shape (default: 0.5)",
84
+ )
85
+ parser.add_argument(
86
+ "--disk-radius",
87
+ type=int,
88
+ default=None,
89
+ help="Reserved disk radius in pixels; auto-computed if omitted",
90
+ )
91
+ parser.add_argument(
92
+ "--area-floor",
93
+ type=float,
94
+ default=0.5,
95
+ help="Minimum fraction of target area a zone can be reduced to (default: 0.5)",
96
+ )
97
+ parser.add_argument(
98
+ "--validate",
99
+ action="store_true",
100
+ help="Run structural invariant checks on output before writing",
101
+ )
102
+ parser.add_argument(
103
+ "--render",
104
+ action="store_true",
105
+ help="Render the generated DCEL to a PNG image",
106
+ )
107
+ parser.add_argument(
108
+ "--render-output",
109
+ default="dcel_map.png",
110
+ help="Path for the rendered PNG when --render is used",
111
+ )
112
+ parser.add_argument(
113
+ "--quiet",
114
+ action="store_true",
115
+ help="Suppress progress output",
116
+ )
117
+
118
+ args = parser.parse_args()
119
+
120
+ # Validate new parameters
121
+ if args.blob_radius <= 0:
122
+ parser.error("--blob-radius must be > 0")
123
+ if args.disk_radius is not None and args.disk_radius < 1:
124
+ parser.error("--disk-radius must be >= 1")
125
+ if not (0 < args.area_floor <= 1):
126
+ parser.error("--area-floor must be in (0, 1]")
127
+
128
+ zone_edges_arg = args.leaf_graph or args.zone_edges
129
+ zone_edges_path = Path(zone_edges_arg)
130
+ if not zone_edges_path.exists():
131
+ print(f"ERROR: Input file not found: {zone_edges_path}", file=sys.stderr)
132
+ sys.exit(1)
133
+
134
+ from dcel_builder import generate_map_artifacts
135
+ from dcel_builder.frontend_bundle import build_frontend_bundle
136
+ from dcel_builder.serializer import to_json, validate_invariants
137
+
138
+ try:
139
+ if not args.quiet:
140
+ ignored = [
141
+ "--canvas-size",
142
+ "--blob-radius",
143
+ "--disk-radius",
144
+ "--area-floor",
145
+ ]
146
+ print(
147
+ "Tree-first recursive pipeline active; compatibility flags are accepted but "
148
+ "ignored: "
149
+ + ", ".join(ignored)
150
+ )
151
+ dcel, report, tree, zone_index = generate_map_artifacts(
152
+ zone_edges_arg,
153
+ args.tree_stats,
154
+ args.zone_index,
155
+ seed=args.seed,
156
+ resolution=args.resolution,
157
+ land_fraction=args.land_fraction,
158
+ noise_exponent=args.noise_exponent,
159
+ warp_strength=args.warp_strength,
160
+ quiet=args.quiet,
161
+ blob_radius=args.blob_radius,
162
+ disk_radius=args.disk_radius,
163
+ area_floor=args.area_floor,
164
+ )
165
+ except ValueError as e:
166
+ msg = str(e)
167
+ if "no valid seed" in msg.lower():
168
+ print(f"ERROR: Insufficient land pixels. {msg}", file=sys.stderr)
169
+ sys.exit(3)
170
+ print(f"ERROR: Invalid input. {msg}", file=sys.stderr)
171
+ sys.exit(2)
172
+
173
+ if args.validate:
174
+ if not validate_invariants(dcel):
175
+ print("ERROR: DCEL structural validation failed.", file=sys.stderr)
176
+ sys.exit(4)
177
+
178
+ data = to_json(dcel, {})
179
+ output_path = Path(args.output)
180
+ output_path.parent.mkdir(parents=True, exist_ok=True)
181
+ output_path.write_text(json.dumps(data, indent=2))
182
+
183
+ frontend_bundle_path = None
184
+ if args.frontend_bundle is not None:
185
+ frontend_bundle = build_frontend_bundle(dcel, tree, zone_index)
186
+ frontend_bundle_path = Path(args.frontend_bundle)
187
+ frontend_bundle_path.parent.mkdir(parents=True, exist_ok=True)
188
+ frontend_bundle_path.write_text(json.dumps(frontend_bundle, indent=2))
189
+
190
+ render_path = None
191
+ if args.render:
192
+ from dcel_builder.render import render_dcel
193
+
194
+ render_path = Path(args.render_output)
195
+ render_path.parent.mkdir(parents=True, exist_ok=True)
196
+ render_dcel(dcel, render_path)
197
+
198
+ if not args.quiet:
199
+ interior = sum(1 for f in dcel.faces if not f.is_outer)
200
+ message = (
201
+ f"DCEL map written to {output_path} "
202
+ f"({interior} leaf faces, root {report['root_id']}, max depth {report['max_depth']})"
203
+ )
204
+ if frontend_bundle_path is not None:
205
+ message += f"; frontend bundle written to {frontend_bundle_path}"
206
+ if render_path is not None:
207
+ message += f"; render written to {render_path}"
208
+ print(message)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
dcel_builder/dcel.py ADDED
@@ -0,0 +1,174 @@
1
+ """T006: DCEL data structure — Vertex, HalfEdge, Face, and DCEL container."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+
9
+ @dataclass
10
+ class Vertex:
11
+ """A 2-D point in the DCEL map."""
12
+
13
+ x: float
14
+ y: float
15
+ incident_edge: int # index of one outgoing half-edge
16
+
17
+
18
+ @dataclass
19
+ class HalfEdge:
20
+ """A directed half-edge in the DCEL."""
21
+
22
+ origin: int # index of origin Vertex
23
+ twin: int # index of the opposite half-edge
24
+ next: int # index of next half-edge in CCW face cycle
25
+ prev: int # index of previous half-edge in same cycle
26
+ incident_face: int # index of face to the left of this half-edge
27
+
28
+
29
+ @dataclass
30
+ class Face:
31
+ """A polygon face in the DCEL (or the unbounded outer face)."""
32
+
33
+ outer_component: Optional[int] # index of one bounding half-edge; None for outer
34
+ is_outer: bool
35
+ zone_id: Optional[int] # node ID from zone_leaf_graph; None for outer
36
+ area: float = field(default=0.0)
37
+ target_area: float = field(default=0.0)
38
+
39
+
40
+ class DCEL:
41
+ """Doubly-Connected Edge List container.
42
+
43
+ Holds flat lists of Vertex, HalfEdge, and Face objects.
44
+ Array position = implicit index for all cross-references.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ vertices: list[Vertex],
50
+ halfedges: list[HalfEdge],
51
+ faces: list[Face],
52
+ ) -> None:
53
+ self.vertices = vertices
54
+ self.halfedges = halfedges
55
+ self.faces = faces
56
+
57
+ # ------------------------------------------------------------------
58
+ # Structural validation
59
+ # ------------------------------------------------------------------
60
+
61
+ def validate(self) -> None:
62
+ """Check all four DCEL structural invariants.
63
+
64
+ Raises
65
+ ------
66
+ ValueError
67
+ Lists every violated invariant found.
68
+ """
69
+ violations: list[str] = []
70
+ he = self.halfedges
71
+
72
+ for i, h in enumerate(he):
73
+ # 1. twin.twin == self
74
+ if he[h.twin].twin != i:
75
+ violations.append(
76
+ f"twin invariant violated at he[{i}]: "
77
+ f"he[{h.twin}].twin = {he[h.twin].twin}, expected {i}"
78
+ )
79
+
80
+ # 2. next.prev == self
81
+ if he[h.next].prev != i:
82
+ violations.append(
83
+ f"next.prev invariant violated at he[{i}]: "
84
+ f"he[{h.next}].prev = {he[h.next].prev}, expected {i}"
85
+ )
86
+
87
+ # 3. twin faces differ
88
+ if he[h.twin].incident_face == h.incident_face:
89
+ violations.append(
90
+ f"twin face invariant violated at he[{i}]: "
91
+ f"he[{i}].incident_face == he[{h.twin}].incident_face == "
92
+ f"{h.incident_face}"
93
+ )
94
+
95
+ # 4. Outer face must have outer_component == None
96
+ for j, f in enumerate(self.faces):
97
+ if f.is_outer and f.outer_component is not None:
98
+ violations.append(
99
+ f"outer face invariant violated at face[{j}]: "
100
+ f"outer_component should be None, got {f.outer_component}"
101
+ )
102
+
103
+ if violations:
104
+ raise ValueError("DCEL structural validation failed:\n" + "\n".join(violations))
105
+
106
+ # ------------------------------------------------------------------
107
+ # Geometry helpers (added in US2 / T021)
108
+ # ------------------------------------------------------------------
109
+
110
+ def compute_face_areas(
111
+ self,
112
+ pos: dict[int, tuple[float, float]],
113
+ target_map: Optional[dict[int, float]] = None,
114
+ ) -> None:
115
+ """Compute and store the polygon area for every interior face.
116
+
117
+ Uses the shoelace formula. Results are stored in ``face.area``.
118
+ If *target_map* is provided (zone_id → target_area), also sets
119
+ ``face.target_area``.
120
+
121
+ Parameters
122
+ ----------
123
+ pos:
124
+ Mapping from graph node IDs to (x, y) coordinates.
125
+ target_map:
126
+ Optional mapping from zone_id to target area.
127
+ """
128
+ # Store vertex positions keyed by vertex index for quick lookup.
129
+ vert_pos = {i: (v.x, v.y) for i, v in enumerate(self.vertices)}
130
+
131
+ # Build a coordinate → vertex_index map
132
+ coord_to_vidx = {(v.x, v.y): i for i, v in enumerate(self.vertices)}
133
+
134
+ # Build vertex_index → node_id map using pos
135
+ vidx_to_node: dict[int, int] = {}
136
+ for node_id, (x, y) in pos.items():
137
+ key = (x, y)
138
+ if key in coord_to_vidx:
139
+ vidx_to_node[coord_to_vidx[key]] = node_id
140
+
141
+ for face_idx, face in enumerate(self.faces):
142
+ if face.is_outer:
143
+ continue
144
+ if face.outer_component is None:
145
+ continue
146
+
147
+ # Walk the half-edge boundary cycle to collect vertex coords
148
+ start = face.outer_component
149
+ coords: list[tuple[float, float]] = []
150
+ cur = start
151
+ while True:
152
+ v_idx = self.halfedges[cur].origin
153
+ coords.append(vert_pos[v_idx])
154
+ cur = self.halfedges[cur].next
155
+ if cur == start:
156
+ break
157
+
158
+ # Shoelace formula
159
+ area = _shoelace_area(coords)
160
+ face.area = abs(area)
161
+
162
+ if target_map is not None and face.zone_id is not None:
163
+ face.target_area = target_map.get(face.zone_id, 0.0)
164
+
165
+
166
+ def _shoelace_area(coords: list[tuple[float, float]]) -> float:
167
+ """Return signed area via the shoelace formula (positive = CCW)."""
168
+ n = len(coords)
169
+ s = 0.0
170
+ for i in range(n):
171
+ x0, y0 = coords[i]
172
+ x1, y1 = coords[(i + 1) % n]
173
+ s += x0 * y1 - x1 * y0
174
+ return s / 2.0
@@ -0,0 +1,184 @@
1
+ """Build a frontend-oriented hierarchy bundle from the generated DCEL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from itertools import combinations
6
+ from functools import lru_cache
7
+
8
+ from shapely.geometry import GeometryCollection, LineString, MultiLineString, MultiPolygon, Polygon
9
+ from shapely.geometry.polygon import orient
10
+ from shapely.ops import unary_union
11
+
12
+ from dcel_builder.dcel import DCEL
13
+ from dcel_builder.geometry import face_polygon_coords
14
+ from dcel_builder.tree_loader import ZoneTree
15
+
16
+
17
+ def build_frontend_bundle(dcel: DCEL, tree: ZoneTree, zone_index: dict[int, str]) -> dict:
18
+ """Return a frontend-ready hierarchy bundle with geometry for every zone."""
19
+ leaf_polygons = _leaf_polygons(dcel)
20
+
21
+ @lru_cache(maxsize=None)
22
+ def zone_geometry(zone_id: int):
23
+ children = tree.children[zone_id]
24
+ if not children:
25
+ return leaf_polygons[zone_id]
26
+ return _normalize_geometry(unary_union([zone_geometry(child) for child in children]))
27
+
28
+ zone_geometries = {zone_id: zone_geometry(zone_id) for zone_id in sorted(tree.nodes)}
29
+ zones: dict[str, dict] = {}
30
+ for zone_id in sorted(tree.nodes):
31
+ geometry = zone_geometries[zone_id]
32
+ min_x, min_y, max_x, max_y = geometry.bounds
33
+ children = list(tree.children[zone_id])
34
+ depth = tree.depth[zone_id]
35
+ zones[str(zone_id)] = {
36
+ "id": zone_id,
37
+ "name": zone_index.get(zone_id, str(zone_id)),
38
+ "parent_id": tree.parent[zone_id],
39
+ "depth": depth,
40
+ "child_ids": children,
41
+ "is_leaf": not children,
42
+ "bbox": [min_x, min_y, max_x, max_y],
43
+ "area": geometry.area,
44
+ "path": _svg_path(geometry),
45
+ "children_reveal_depth": depth + 1 if children else None,
46
+ }
47
+
48
+ borders = _shared_borders(zone_geometries, tree)
49
+ levels = {
50
+ str(depth): [zone_id for zone_id in sorted(tree.nodes) if tree.depth[zone_id] == depth]
51
+ for depth in range(tree.max_depth + 1)
52
+ }
53
+ return {
54
+ "root_id": tree.root,
55
+ "max_depth": tree.max_depth,
56
+ "world_bbox": [0.0, 0.0, 1.0, 1.0],
57
+ "zoom_depth_thresholds": {
58
+ str(depth): float(2**depth)
59
+ for depth in range(tree.max_depth + 1)
60
+ },
61
+ "levels": levels,
62
+ "borders": borders,
63
+ "zones": zones,
64
+ }
65
+
66
+
67
+ def _leaf_polygons(dcel: DCEL) -> dict[int, Polygon]:
68
+ polygons: dict[int, Polygon] = {}
69
+ for face in dcel.faces:
70
+ if face.is_outer or face.zone_id is None or face.outer_component is None:
71
+ continue
72
+ coords = face_polygon_coords(dcel, face.outer_component)
73
+ if len(coords) < 3:
74
+ raise ValueError(f"Face for zone {face.zone_id} is degenerate.")
75
+ polygons[face.zone_id] = orient(Polygon(coords), sign=1.0)
76
+ return polygons
77
+
78
+
79
+ def _normalize_geometry(geometry):
80
+ if isinstance(geometry, Polygon):
81
+ return orient(geometry, sign=1.0)
82
+ if isinstance(geometry, MultiPolygon):
83
+ return MultiPolygon([orient(polygon, sign=1.0) for polygon in geometry.geoms])
84
+ raise ValueError(f"Expected polygon geometry, got {geometry.geom_type}")
85
+
86
+
87
+ def _svg_path(geometry) -> str:
88
+ if isinstance(geometry, Polygon):
89
+ return _polygon_path(geometry)
90
+ if isinstance(geometry, MultiPolygon):
91
+ return " ".join(_polygon_path(polygon) for polygon in geometry.geoms)
92
+ raise ValueError(f"Expected polygon geometry, got {geometry.geom_type}")
93
+
94
+
95
+ def _polygon_path(polygon: Polygon) -> str:
96
+ segments = [_ring_path(list(polygon.exterior.coords))]
97
+ segments.extend(_ring_path(list(interior.coords)) for interior in polygon.interiors)
98
+ return " ".join(segments)
99
+
100
+
101
+ def _ring_path(coords: list[tuple[float, float]]) -> str:
102
+ commands = [f"M{_fmt(coords[0][0])},{_fmt(coords[0][1])}"]
103
+ commands.extend(f"L{_fmt(x)},{_fmt(y)}" for x, y in coords[1:])
104
+ commands.append("Z")
105
+ return " ".join(commands)
106
+
107
+
108
+ def _fmt(value: float) -> str:
109
+ text = f"{value:.6f}"
110
+ return text.rstrip("0").rstrip(".") if "." in text else text
111
+
112
+
113
+ def _shared_borders(zone_geometries: dict[int, Polygon | MultiPolygon], tree: ZoneTree) -> list[dict]:
114
+ borders: list[dict] = []
115
+ for left_zone, right_zone in combinations(sorted(zone_geometries), 2):
116
+ if _related_by_ancestry(tree, left_zone, right_zone):
117
+ continue
118
+ left_geometry = zone_geometries[left_zone]
119
+ right_geometry = zone_geometries[right_zone]
120
+ if not _bounds_intersect(left_geometry.bounds, right_geometry.bounds):
121
+ continue
122
+ shared = left_geometry.boundary.intersection(right_geometry.boundary)
123
+ shared_lines = _as_multiline(shared)
124
+ if shared_lines is None or shared_lines.length <= 1e-9:
125
+ continue
126
+ borders.append(
127
+ {
128
+ "id": f"{left_zone}:{right_zone}",
129
+ "zone_ids": [left_zone, right_zone],
130
+ "path": _line_path(shared_lines),
131
+ }
132
+ )
133
+ return borders
134
+
135
+
136
+ def _related_by_ancestry(tree: ZoneTree, left_zone: int, right_zone: int) -> bool:
137
+ current = tree.parent[left_zone]
138
+ while current is not None:
139
+ if current == right_zone:
140
+ return True
141
+ current = tree.parent[current]
142
+
143
+ current = tree.parent[right_zone]
144
+ while current is not None:
145
+ if current == left_zone:
146
+ return True
147
+ current = tree.parent[current]
148
+ return False
149
+
150
+
151
+ def _as_multiline(geometry) -> MultiLineString | None:
152
+ if geometry.is_empty:
153
+ return None
154
+ if isinstance(geometry, LineString):
155
+ return MultiLineString([geometry])
156
+ if isinstance(geometry, MultiLineString):
157
+ return geometry
158
+ if isinstance(geometry, GeometryCollection):
159
+ lines = [geom for geom in geometry.geoms if isinstance(geom, LineString) and geom.length > 0]
160
+ return MultiLineString(lines) if lines else None
161
+ return None
162
+
163
+
164
+ def _line_path(geometry: MultiLineString) -> str:
165
+ commands: list[str] = []
166
+ for line in geometry.geoms:
167
+ coords = list(line.coords)
168
+ if len(coords) < 2:
169
+ continue
170
+ commands.append(f"M{_fmt(coords[0][0])},{_fmt(coords[0][1])}")
171
+ commands.extend(f"L{_fmt(x)},{_fmt(y)}" for x, y in coords[1:])
172
+ return " ".join(commands)
173
+
174
+
175
+ def _bounds_intersect(
176
+ left_bounds: tuple[float, float, float, float],
177
+ right_bounds: tuple[float, float, float, float],
178
+ ) -> bool:
179
+ return not (
180
+ left_bounds[2] < right_bounds[0]
181
+ or right_bounds[2] < left_bounds[0]
182
+ or left_bounds[3] < right_bounds[1]
183
+ or right_bounds[3] < left_bounds[1]
184
+ )