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.
- dcel_builder/__init__.py +130 -0
- dcel_builder/__main__.py +212 -0
- dcel_builder/dcel.py +174 -0
- dcel_builder/frontend_bundle.py +184 -0
- dcel_builder/geometry.py +20 -0
- dcel_builder/hierarchy.py +553 -0
- dcel_builder/noise.py +89 -0
- dcel_builder/raster_dcel.py +252 -0
- dcel_builder/render.py +107 -0
- dcel_builder/serializer.py +99 -0
- dcel_builder/tree_loader.py +146 -0
- dcel_map_generator-0.2.2.dist-info/METADATA +164 -0
- dcel_map_generator-0.2.2.dist-info/RECORD +16 -0
- dcel_map_generator-0.2.2.dist-info/WHEEL +4 -0
- dcel_map_generator-0.2.2.dist-info/entry_points.txt +2 -0
- dcel_map_generator-0.2.2.dist-info/licenses/LICENSE +21 -0
dcel_builder/__init__.py
ADDED
|
@@ -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
|
dcel_builder/__main__.py
ADDED
|
@@ -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
|
+
)
|