refactor: 抽 osmassets 包,要素注册表,材质单一定义源 (P0-P3)
generate_scene.py 从 1271 → 816 行 (-455)
P0: 纯函数搬家
- osmassets/osm.py: parse_osm / Projector / parse_height
- osmassets/geom.py: clip_polygon / point_in_polygon / distance_to_ring / sample_tree_row 等
- blender/tests/test_pure.py: 42 个 unittest (脱离 bpy 运行)
P1: 单一定义源
- osmassets/catalog.py: ROAD_LAYERS + MATERIALS (含 cesium 导出参数)
- 对接 osm2streets_scene_style.json 做图层一致性 warning
- 干掉 road_mats / layer_z / 材质参数三份副本
P2: 要素注册表
- osmassets/{water,grass,scrub}.py: 每个要素一个 assemble() 函数
- build() 中的 if/elif 链收缩为注册表调用
- 计数器集中到 counts 字典
P3: 材质契约化
- catalog.py 扩展 CESIUM_EXPORT 段 (tint/metallic/emission)
- 标记已发现的死条目 Office White Metal Facade (四表各一组)
校验:
- parity.js + scene_digest.py + glb-digest.js 三位一体
- control-1 vs p0/p1/p2a/p2b/p3-counts: 两区域全 PARITY OK
- 42 个纯 Python 测试全部通过
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
148
blender/osmassets/geom.py
Normal file
148
blender/osmassets/geom.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Planar geometry helpers for the OSM → asset pipeline.
|
||||
|
||||
Pure Python: no `bpy`, so this runs and tests outside Blender. All functions
|
||||
work in projected metres (see `osmassets.osm.Projector`) unless the name says
|
||||
otherwise; `geometry_rings` and `feature_in_bounds` take raw GeoJSON and are the
|
||||
two exceptions, operating on lon/lat.
|
||||
|
||||
Rings are lists of (x, y) tuples. A repeated closing point is tolerated
|
||||
everywhere but never required.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def geometry_rings(geometry):
|
||||
"""Exterior rings of a GeoJSON Polygon/MultiPolygon; holes are dropped."""
|
||||
if not geometry:
|
||||
return []
|
||||
kind = geometry.get("type")
|
||||
coordinates = geometry.get("coordinates", [])
|
||||
if kind == "Polygon":
|
||||
return coordinates[:1]
|
||||
if kind == "MultiPolygon":
|
||||
return [polygon[0] for polygon in coordinates if polygon]
|
||||
return []
|
||||
|
||||
|
||||
def feature_in_bounds(feature, projector):
|
||||
"""True when any coordinate of the feature falls inside the padded bounds."""
|
||||
def walk(value):
|
||||
if isinstance(value, list) and value and isinstance(value[0], (int, float)):
|
||||
return projector.inside(value)
|
||||
return any(walk(v) for v in value) if isinstance(value, list) else False
|
||||
return walk(feature.get("geometry", {}).get("coordinates", []))
|
||||
|
||||
|
||||
def clip_polygon(ring, xmin, xmax, ymin, ymax):
|
||||
"""Sutherland-Hodgman clip of a ring against an axis-aligned box."""
|
||||
if len(ring) < 3:
|
||||
return []
|
||||
|
||||
def clip_edge(points, inside, intersection):
|
||||
if not points:
|
||||
return []
|
||||
result = []
|
||||
previous = points[-1]
|
||||
previous_inside = inside(previous)
|
||||
for current in points:
|
||||
current_inside = inside(current)
|
||||
if current_inside != previous_inside:
|
||||
result.append(intersection(previous, current))
|
||||
if current_inside:
|
||||
result.append(current)
|
||||
previous = current
|
||||
previous_inside = current_inside
|
||||
return result
|
||||
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[0] >= xmin,
|
||||
lambda a, b: (xmin, a[1] + (b[1] - a[1]) * (xmin - a[0]) /
|
||||
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[0] <= xmax,
|
||||
lambda a, b: (xmax, a[1] + (b[1] - a[1]) * (xmax - a[0]) /
|
||||
(b[0] - a[0]) if b[0] != a[0] else a[1]))
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[1] >= ymin,
|
||||
lambda a, b: (a[0] + (b[0] - a[0]) * (ymin - a[1]) /
|
||||
(b[1] - a[1]) if b[1] != a[1] else a[0], ymin))
|
||||
ring = clip_edge(
|
||||
ring, lambda p: p[1] <= ymax,
|
||||
lambda a, b: (a[0] + (b[0] - a[0]) * (ymax - a[1]) /
|
||||
(b[1] - a[1]) if b[1] != a[1] else a[0], ymax))
|
||||
return ring
|
||||
|
||||
|
||||
def sample_tree_row(points, spacing, height):
|
||||
"""Evenly space (x, y, height) samples along a polyline.
|
||||
|
||||
The trailing point is appended only when the last regular sample stops well
|
||||
short of it, so a row does not end in a double-planted tree.
|
||||
"""
|
||||
if len(points) < 2:
|
||||
return []
|
||||
samples = [(points[0][0], points[0][1], height)]
|
||||
distance_until_next = spacing
|
||||
for start, end in zip(points, points[1:]):
|
||||
dx = end[0] - start[0]
|
||||
dy = end[1] - start[1]
|
||||
segment_length = math.hypot(dx, dy)
|
||||
if segment_length == 0:
|
||||
continue
|
||||
while distance_until_next <= segment_length:
|
||||
ratio = distance_until_next / segment_length
|
||||
samples.append((start[0] + dx * ratio, start[1] + dy * ratio, height))
|
||||
distance_until_next += spacing
|
||||
distance_until_next -= segment_length
|
||||
last = points[-1]
|
||||
if math.hypot(samples[-1][0] - last[0], samples[-1][1] - last[1]) > spacing * 0.45:
|
||||
samples.append((last[0], last[1], height))
|
||||
return samples
|
||||
|
||||
|
||||
def polygon_area(ring):
|
||||
"""Unsigned shoelace area; 0.0 for degenerate rings."""
|
||||
if len(ring) < 3:
|
||||
return 0.0
|
||||
area = 0.0
|
||||
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
||||
area += x1 * y2 - x2 * y1
|
||||
return abs(area) * 0.5
|
||||
|
||||
|
||||
def point_in_polygon(point, ring):
|
||||
x, y = point
|
||||
inside = False
|
||||
j = len(ring) - 1
|
||||
for i, (xi, yi) in enumerate(ring):
|
||||
xj, yj = ring[j]
|
||||
crosses = ((yi > y) != (yj > y))
|
||||
if crosses:
|
||||
x_at_y = (xj - xi) * (y - yi) / (yj - yi) + xi
|
||||
if x < x_at_y:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def distance_to_ring(point, ring):
|
||||
"""Shortest distance from a point to the ring's edges (not its interior)."""
|
||||
px, py = point
|
||||
best = float("inf")
|
||||
count = len(ring)
|
||||
for index in range(count):
|
||||
ax, ay = ring[index]
|
||||
bx, by = ring[(index + 1) % count]
|
||||
dx = bx - ax
|
||||
dy = by - ay
|
||||
length_sq = dx * dx + dy * dy
|
||||
if length_sq <= 1e-9:
|
||||
distance = math.hypot(px - ax, py - ay)
|
||||
else:
|
||||
t = ((px - ax) * dx + (py - ay) * dy) / length_sq
|
||||
t = max(0.0, min(1.0, t))
|
||||
distance = math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
if distance < best:
|
||||
best = distance
|
||||
return best
|
||||
Reference in New Issue
Block a user