dc-l1-tokyo-ring-orientation
Map (reference ← swipe → agent)
0:00
tokyo_buildings_legacy.geojson is a footprint export from an old in-house tool. When we load it into web-based GeoJSON viewers, building interiors are shaded incorrectly: holes appear filled and exteriors render as empty space. Our tile-server import script also rejects every feature with geometry warnings. Fix the geometries so that standard GeoJSON web viewers render them correctly. Attributes must be untouched. Output to tokyo_buildings_fixed.geojson.
Every feature must remain a Polygon with interior rings and holes preserved exactly as they are; do not flatten them. feature_id is the join key.
0:00
System info
{
"adapter": "openrouter",
"model": "deepseek/deepseek-v4-flash",
"base_url": "https://openrouter.ai/api/v1"
}
0:03
Let me start by examining the input file to understand its structure and what issues might exist with the geometries.
0:03
Read
/work/tokyo_buildings_legacy.geojson
1 {
2 "type": "FeatureCollection",
3 "name": "tokyo_buildings_legacy",
4 "crs": {
5 "type": "name",
6 "properties": {
7 "name": "urn:ogc:def:crs:OGC:1.3:CRS84"
8 }
9 },
10 "features": [
11 {
12 "type": "Feature",
13 "geometry": {
14 "type": "Polygon",
15 "coordinates": [
16 [
17 [
18 139.7029081,
19 35.6592372
20 ],
21 [
22 139.7027971,
23 35.659202
24 ],
25 [
26 139.7027755,
27 35.6592471
28 ],
29 [
30 139.7028864,
31 35.6592823
32 ],
33 [
34 139.7029081,
35 35.6592372
36 ]
37 ]
38 ]
39 },
40 "properties": {
41 "feature_id": 1,
42 "overture_id": "01302ced-3141-4b64-b630-98019352a9fd",
43 "name_primary": "渋谷協和ビル",
44 "building_class": "",
45 "height": null
46 }
47 },
48 {
49 "type": "Feature",
50 "geometry": {
51 "type": "Polygon",
52 "coordinates": [
53 [
54 [
55 139.7010896,
56 35.6601981
57 ],
58 [
59 139.7010851,
60 35.660084
61 ],
62 [
63 139.7010559,
64 35.660088
65 ],
66 [
67 139.7010511,
68 35.6600707
69 ],
70 [
71 139.7009769,
72 35.6600834
73 ],
74 [
75 139.7009923,
76 35.6601243
77 ],
78 [
79 139.7009007,
80 35.6601417
81 ],
82 [
83 139.7009195,
84 35.6602237
85 ],
86 [
87 139.7010896,
88 35.6601981
89 ]
90 ]
91 ]
92 },
93 "properties": {
94 "feature_id": 2,
95 "overture_id": "046ab130-a9a2-4a0e-9142-b7ea36374fdb",
96 "name_primary": "丸大ビル",
97 "building_class": "",
98 "height": null
99 }
100 },
<system-reminder>Showed lines 1-100 of 4430. Re-call Read with offset=101 to continue, or raise limit.</system-reminder>
0:19
Let me see more of the file, especially looking for cases with holes (interior rings) to understand the problematic pattern.
0:19
Bash
cd /work && python -c "
cd /work && python -c "
import json
with open('tokyo_buildings_legacy.geojson') as f:
data = json.load(f)
print('Total features:', len(data['features']))
# Find features with holes (interior rings)
holes_count = 0
for feat in data['features']:
coords = feat['geometry']['coordinates']
if len(coords) > 1:
holes_count += 1
print('Feature', feat['properties']['feature_id'], 'has', len(coords)-1, 'holes')
print(' Coordinates structure:')
for i, ring in enumerate(coords):
print(f' Ring {i}: {len(ring)} points')
print('Total features with holes:', holes_count)
"
Total features: 100
Feature 6 has 1 holes
Coordinates structure:
Ring 0: 9 points
Ring 1: 5 points
Feature 18 has 1 holes
Coordinates structure:
Ring 0: 6 points
Ring 1: 5 points
Feature 32 has 1 holes
Coordinates structure:
Ring 0: 5 points
Ring 1: 5 points
Feature 50 has 1 holes
Coordinates structure:
Ring 0: 5 points
Ring 1: 5 points
Feature 74 has 1 holes
Coordinates structure:
Ring 0: 5 points
Ring 1: 5 points
Total features with holes: 5
0:32
Let me understand the problem. The issue is that when loaded into web-based GeoJSON viewers, the buildings render incorrectly - holes appear filled and exteriors render as empty space. This is a classic winding order (ring orientation) problem. In GeoJSON, according to RFC 7946: - **Polygon exterior rings** must have a **counterclockwise** winding order (right-hand rule) - **Interior rings (holes)** must have a **clockwise** winding order However, the legacy data might have the wrong orientation (exterior clockwise, interior counterclockwise), which causes web viewers to interpret them incorrectly. Let me check the winding orders.
0:32
Bash
cd /work && python -c "
cd /work && python -c "
import json
from shapely.geometry import shape, Polygon
from shapely import wkt
with open('tokyo_buildings_legacy.geojson') as f:
data = json.load(f)
for feat in data['features'][:10]: # Check first 10
geom = shape(feat['geometry'])
if geom.geom_type == 'Polygon':
# Check the signed area to determine orientation
# Positive area = counterclockwise (exterior should be CCW)
# Negative area = clockwise (exterior should be CCW in GeoJSON RFC 7946)
coords = feat['geometry']['coordinates']
ext_ring = coords[0]
# Compute signed area
area = 0
n = len(ext_ring)
for i in range(n - 1):
x1, y1 = ext_ring[i]
x2, y2 = ext_ring[i + 1]
area += (x2 - x1) * (y1 + y2)
print(f'Feature {feat[\"properties\"][\"feature_id\"]}: exterior area sign = {area:.6f} ({\"CW\" if area < 0 else \"CCW\"})')
if len(coords) > 1:
for j, ring in enumerate(coords[1:], 1):
area2 = 0
n2 = len(ring)
for i in range(n2 - 1):
x1, y1 = ring[i]
x2, y2 = ring[i + 1]
area2 += (x2 - x1) * (y1 + y2)
print(f' Hole {j}: area sign = {area2:.6f} ({\"CW\" if area2 < 0 else \"CCW\"})')
"
Feature 1: exterior area sign = 0.000000 (CCW) Feature 2: exterior area sign = 0.000000 (CCW) Feature 3: exterior area sign = 0.000000 (CCW) Feature 4: exterior area sign = 0.000000 (CCW) Feature 5: exterior area sign = 0.000000 (CCW) Feature 6: exterior area sign = 0.000000 (CCW) Hole 1: area sign = -0.000000 (CW) Feature 7: exterior area sign = 0.000000 (CCW) Feature 8: exterior area sign = 0.000000 (CCW) Feature 9: exterior area sign = 0.000000 (CCW) Feature 10: exterior area sign = 0.000000 (CCW)
0:43
The areas are very small (0.000000) because the coordinates are in degrees. Let me multiply by a larger factor to see the sign.
0:43
Bash
cd /work && python -c "
cd /work && python -c "
import json
from shapely.geometry import shape, Polygon
from shapely import wkt
with open('tokyo_buildings_legacy.geojson') as f:
data = json.load(f)
# Check orientation using shapely's orientation
for feat in data['features']:
geom = shape(feat['geometry'])
if geom.geom_type == 'Polygon':
fid = feat['properties']['feature_id']
coords = feat['geometry']['coordinates']
# Exterior ring
ext_ring = coords[0]
# ShoeLace formula for signed area
signed_area = 0.0
n = len(ext_ring)
for i in range(n - 1):
x1, y1 = ext_ring[i]
x2, y2 = ext_ring[i + 1]
signed_area += (x2 - x1) * (y1 + y2)
# Actually the Shoelace formula is: 0.5 * sum(x_i*y_{i+1} - x_{i+1}*y_i)
# Let me use the proper formula
signed_area2 = 0.0
for i in range(n - 1):
x1, y1 = ext_ring[i]
x2, y2 = ext_ring[i + 1]
signed_area2 += (x1 * y2 - x2 * y1)
signed_area2 /= 2.0
ext_orientation = 'CCW' if signed_area2 > 0 else ('CW' if signed_area2 < 0 else 'Flat')
print(f'Feature {fid}: exterior = {ext_orientation} (signed area = {signed_area2:.10f})')
if len(coords) > 1:
for j, ring in enumerate(coords[1:], 1):
signed_area_inner = 0.0
n2 = len(ring)
for i in range(n2 - 1):
x1, y1 = ring[i]
x2, y2 = ring[i + 1]
signed_area_inner += (x1 * y2 - x2 * y1)
signed_area_inner /= 2.0
inner_orientation = 'CCW' if signed_area_inner > 0 else ('CW' if signed_area_inner < 0 else 'Flat')
print(f' Hole {j}: {inner_orientation} (signed area = {signed_area_inner:.10f})')
"
Feature 1: exterior = CW (signed area = -0.0000000058) Feature 2: exterior = CW (signed area = -0.0000000195) Feature 3: exterior = CW (signed area = -0.0000000066) Feature 4: exterior = CW (signed area = -0.0000000060) Feature 5: exterior = CW (signed area = -0.0000000081) Feature 6: exterior = CW (signed area = -0.0000000233) Hole 1: CCW (signed area = 0.0000000001) Feature 7: exterior = CW (signed area = -0.0000000015) Feature 8: exterior = CW (signed area = -0.0000000039) Feature 9: exterior = CW (signed area = -0.0000000016) Feature 10: exterior = CW (signed area = -0.0000000069) Feature 11: exterior = CW (signed area = -0.0000000094) Feature 12: exterior = CW (signed area = -0.0000000109) Feature 13: exterior = CW (signed area = -0.0000000009) Feature 14: exterior = CW (signed area = -0.0000000183) Feature 15: exterior = CW (signed area = -0.0000000234) Feature 16: exterior = CW (signed area = -0.0000000178) Feature 17: exterior = CW (signed area = -0.0000000282) Feature 18: exterior = CW (signed area = -0.0000000252) Hole 1: CCW (signed area = 0.0000000001) Feature 19: exterior = CW (signed area = -0.0000000143) Feature 20: exterior = CW (signed area = -0.0000000061) Feature 21: exterior = CW (signed area = -0.0000001147) Feature 22: exterior = CW (signed area = -0.0000000026) Feature 23: exterior = CW (signed area = -0.0000000123) Feature 24: exterior = CW (signed area = -0.0000000288) Feature 25: exterior = CW (signed area = -0.0000000136) Feature 26: exterior = CW (signed area = -0.0000000330) Feature 27: exterior = CW (signed area = -0.0000000089) Feature 28: exterior = CW (signed area = -0.0000000026) Feature 29: exterior = CW (signed area = -0.0000000091) Feature 30: exterior = CW (signed area = -0.0000000121) Feature 31: exterior = CW (signed area = -0.0000000304) Feature 32: exterior = CW (signed area = -0.0000000021) Hole 1: CCW (signed area = 0.0000000000) Feature 33: exterior = CW (signed area = -0.0000000094) Feature 34: exterior = CW (signed area = -0.0000000055) Feature 35: exterior = CW (signed area = -0.0000000370) Feature 36: exterior = CW (signed area = -0.0000000196) Feature 37: exterior = CW (signed area = -0.0000000246) Feature 38: exterior = CW (signed area = -0.0000000180) Feature 39: exterior = CW (signed area = -0.0000000291) Feature 40: exterior = CW (signed area = -0.0000002621) Feature 41: exterior = CW (signed area = -0.0000001406) Feature 42: exterior = CW (signed area = -0.0000000253) Feature 43: exterior = CW (signed area = -0.0000000526) Feature 44: exterior = CW (signed area = -0.0000000225) Feature 45: exterior = CW (signed area = -0.0000000036) Feature 46: exterior = CW (signed area = -0.0000000032) Feature 47: exterior = CW (signed area = -0.0000000027) Feature 48: exterior = CW (signed area = -0.0000000200) Feature 49: exterior = CW (signed area = -0.0000000250) Feature 50: exterior = CW (signed area = -0.0000000114) Hole 1: CCW (signed area = 0.0000000000) Feature 51: exterior = CW (signed area = -0.0000000029) Feature 52: exterior = CW (signed area = -0.0000000083) Feature 53: exterior = CW (signed area = -0.0000000166) Feature 54: exterior = CW (signed area = -0.0000000644) Feature 55: exterior = CW (signed area = -0.0000000258) Feature 56: exterior = CW (signed area = -0.0000000610) Feature 57: exterior = CW (signed area = -0.0000000026) Feature 58: exterior = CW (signed area = -0.0000000028) Feature 59: exterior = CW (signed area = -0.0000000066) Feature 60: exterior = CW (signed area = -0.0000000051) Feature 61: exterior = CW (signed area = -0.0000000031) Feature 62: exterior = CW (signed area = -0.0000000353) Feature 63: exterior = CW (signed area = -0.0000000043) Feature 64: exterior = CW (signed area = -0.0000000015) Feature 65: exterior = CW (signed area = -0.0000000723) Feature 66: exterior = CW (signed area = -0.0000000050) Feature 67: exterior = CW (signed area = -0.0000000130) Feature 68: exterior = CW (signed area = -0.0000000130) Feature 69: exterior = CW (signed area = -0.0000000125) Feature 70: exterior = CW (signed area = -0.0000000026) Feature 71: exterior = CW (signed area = -0.0000000015) Feature 72: exterior = CW (signed area = -0.0000000057) Feature 73: exterior = CW (signed area = -0.0000000318) Feature 74: exterior = CW (signed area = -0.0000000087) Hole 1: CCW (signed area = 0.0000000000) Feature 75: exterior = CW (signed area = -0.0000000093) Feature 76: exterior = CW (signed area = -0.0000001116) Feature 77: exterior = CW (signed area = -0.0000000718) Feature 78: exterior = CW (signed area = -0.0000001038) Feature 79: exterior = CW (signed area = -0.0000000234) Feature 80: exterior = CW (signed area = -0.0000000397) Feature 81: exterior = CW (signed area = -0.0000000067) Feature 82: exterior = CW (signed area = -0.0000000025) Feature 83: exterior = CW (signed area = -0.0000000195) Feature 84: exterior = CW (signed area = -0.0000000148) Feature 85: exterior = CW (signed area = -0.0000000143) Feature 86: exterior = CW (signed area = -0.0000001138) Feature 87: exterior = CW (signed area = -0.0000000093) Feature 88: exterior = CW (signed area = -0.0000000546) Feature 89: exterior = CW (signed area = -0.0000000353) Feature 90: exterior = CW (signed area = -0.0000000053) Feature 91: exterior = CW (signed area = -0.0000000217) Feature 92: exterior = CW (signed area = -0.0000000922) Feature 93: exterior = CW (signed area = -0.0000000204) Feature 94: exterior = CW (signed area = -0.0000000178) Feature 95: exterior = CW (signed area = -0.0000000105) Feature 96: exterior = CW (signed area = -0.0000000024) Feature 97: exterior = CW (signed area = -0.0000000724) Feature 98: exterior = CW (signed area = -0.0000000022) Feature 99: exterior = CW (signed area = -0.0000000830) Feature 100: exterior = CW (signed area = -0.0000000325)
1:00
Now I understand the problem: 1. **All exterior rings are clockwise (CW)** - In GeoJSON (RFC 7946), exterior rings must be counter-clockwise (CCW), and interior rings (holes) must be clockwise (CW). The current data has it reversed: exteriors are CW and holes are CCW. 2. This is the typical "winding order" problem. When web viewers see CW exteriors, they interpret them as holes, and CCW holes as exteriors. So the building interiors (which are actually the hole rings) render as filled, and the building exteriors render as empty space. The fix: reverse the orientation of all rings: - Exterior rings (first ring) → make them CCW - Interior rings (holes) → make them CW According to RFC 7946: - A linear ring MUST follow the Right-Hand Rule with respect to the area it bounds: exterior rings are counterclockwise, and interior rings are clockwise. So I need to: 1. For each feature, if the exterior ring is CW, reverse it to CCW 2. For each hole ring, if it's CCW, reverse it to CW But first, I need to make sure I'm detecting orientation correctly with the Shoelace formula on lat/lon data. Since these are small areas in Tokyo, the sign should be correct. Let me write a script to fix this.
1:00
1:00
Write
/work/fix_orientation.py
content · 92 lines · py
"""
Fix GeoJSON polygon ring orientation for RFC 7946 compliance.
Problem: All exterior rings are clockwise (CW) and holes are counter-clockwise (CCW).
GeoJSON requires exterior rings be CCW and interior rings (holes) be CW.
Fix: Reverse the winding order of each ring so that:
- exterior (ring[0]) is CCW
- interior rings (ring[1:]) are CW
"""
import json
import copy
import sys
INPUT_FILE = '/work/tokyo_buildings_legacy.geojson'
OUTPUT_FILE = '/work/tokyo_buildings_fixed.geojson'
def signed_area(ring):
"""Compute signed area of a ring using the shoelace formula.
Positive = CCW, Negative = CW (in standard Cartesian/Y-up coordinates).
For lon/lat on small scales this sign convention holds."""
n = len(ring)
area = 0.0
for i in range(n - 1):
x1, y1 = ring[i]
x2, y2 = ring[i + 1]
area += (x1 * y2 - x2 * y1)
return area / 2.0
def is_clockwise(ring):
"""Check if a ring is oriented clockwise."""
return signed_area(ring) < 0
def fix_ring_orientation(ring, want_clockwise):
"""Reverse the ring if its orientation does not match want_clockwise."""
cw = is_clockwise(ring)
if want_clockwise and not cw:
# Ring is CCW but we want CW → reverse
return list(reversed(ring))
elif not want_clockwise and cw:
# Ring is CW but we want CCW → reverse
return list(reversed(ring))
else:
# Already correct orientation
return ring
def main():
with open(INPUT_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
fix_count = 0
hole_fix_count = 0
for feature in data['features']:
geom = feature['geometry']
if geom['type'] != 'Polygon':
continue
coords = geom['coordinates']
if not coords:
continue
# Fix exterior ring: should be CCW (not clockwise)
exterior = coords[0]
fixed_exterior = fix_ring_orientation(exterior, want_clockwise=False)
if fixed_exterior is not exterior:
fix_count += 1
coords[0] = fixed_exterior
# Fix interior rings (holes): should be CW
for i in range(1, len(coords)):
ring = coords[i]
fixed_ring = fix_ring_orientation(ring, want_clockwise=True)
if fixed_ring is not ring:
hole_fix_count += 1
coords[i] = fixed_ring
with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f'Total features: {len(data["features"])}')
print(f'Exterior rings fixed (CW→CCW): {fix_count}')
print(f'Interior rings fixed (CCW→CW): {hole_fix_count}')
print(f'Output written to: {OUTPUT_FILE}')
if __name__ == '__main__':
main()
Wrote /work/fix_orientation.py (2762 chars).
1:13
Let me run this and also verify with shapely that the resulting geometries are valid.
1:13
Bash
cd /work && python fix_orientation.py
Total features: 100 Exterior rings fixed (CW→CCW): 100 Interior rings fixed (CCW→CW): 5 Output written to: /work/tokyo_buildings_fixed.geojson
1:25
Let me verify the fix by checking the orientations are now correct.
1:25
Bash
cd /work && python -c "
cd /work && python -c "
import json
with open('tokyo_buildings_fixed.geojson') as f:
data = json.load(f)
def signed_area(ring):
n = len(ring)
area = 0.0
for i in range(n - 1):
x1, y1 = ring[i]
x2, y2 = ring[i + 1]
area += (x1 * y2 - x2 * y1)
return area / 2.0
all_ok = True
for feat in data['features']:
geom = feat['geometry']
if geom['type'] != 'Polygon':
continue
coords = geom['coordinates']
fid = feat['properties']['feature_id']
ext = coords[0]
ext_area = signed_area(ext)
ext_cw = ext_area < 0
if ext_cw:
print(f'ERROR: Feature {fid} exterior still CW')
all_ok = False
for j, ring in enumerate(coords[1:], 1):
hole_area = signed_area(ring)
hole_cw = hole_area < 0
if not hole_cw:
print(f'ERROR: Feature {fid} hole {j} still CCW')
all_ok = False
if all_ok:
print('All rings correctly oriented!')
# Check first and last feature
print()
print('Sample: Feature 1 exterior orientation:', 'CCW' if signed_area(data['features'][0]['geometry']['coordinates'][0]) > 0 else 'CW')
print('Feature 6 (has hole) exterior:', 'CCW' if signed_area(data['features'][5]['geometry']['coordinates'][0]) > 0 else 'CW')
print('Feature 6 hole:', 'CW' if signed_area(data['features'][5]['geometry']['coordinates'][1]) < 0 else 'CCW')
# Verify attributes are untouched
print()
print('Comparing attributes...')
with open('/work/tokyo_buildings_legacy.geojson') as f:
orig = json.load(f)
for i, (of, ff) in enumerate(zip(orig['features'], data['features'])):
if of['properties'] != ff['properties']:
print(f'Attribute mismatch at index {i}')
break
else:
print('All attributes match!')
# Verify CRS and top-level structure intact
print()
print('Top-level keys:', list(data.keys()))
print('Name:', data.get('name'))
print('CRS:', data.get('crs'))
"
All rings correctly oriented!
Sample: Feature 1 exterior orientation: CCW
Feature 6 (has hole) exterior: CCW
Feature 6 hole: CW
Comparing attributes...
All attributes match!
Top-level keys: ['type', 'name', 'crs', 'features']
Name: tokyo_buildings_legacy
CRS: {'type': 'name', 'properties': {'name': 'urn:ogc:def:crs:OGC:1.3:CRS84'}}
1:31
The fix is working correctly. Let me also verify with shapely that the geometries are valid now.
1:31
Bash
cd /work && python -c "
cd /work && python -c "
import json
from shapely.geometry import shape
with open('tokyo_buildings_fixed.geojson') as f:
data = json.load(f)
valid_count = 0
invalid_count = 0
for feat in data['features']:
geom = shape(feat['geometry'])
if geom.is_valid:
valid_count += 1
else:
invalid_count += 1
print(f'INVALID: Feature {feat[\"properties\"][\"feature_id\"]} - {geom}')
print(f'Valid: {valid_count}, Invalid: {invalid_count}')
"
Valid: 100, Invalid: 0
1:45
All 100 features are now valid polygon geometries with correct ring orientation. Let me do a final check to ensure holes are preserved (not flattened).
1:45
Bash
cd /work && python -c "
cd /work && python -c "
import json
with open('tokyo_buildings_fixed.geojson') as f:
data = json.load(f)
# Verify holes are preserved
holes_count = 0
for feat in data['features']:
coords = feat['geometry']['coordinates']
if len(coords) > 1:
holes_count += 1
print(f'Feature {feat[\"properties\"][\"feature_id\"]}: {len(coords)-1} hole(s) preserved')
print(f'Total features with holes: {holes_count}')
print(f'All features are Polygon type: {all(f[\"geometry\"][\"type\"] == \"Polygon\" for f in data[\"features\"])}')
print(f'Total features: {len(data[\"features\"])}')
"
Feature 6: 1 hole(s) preserved Feature 18: 1 hole(s) preserved Feature 32: 1 hole(s) preserved Feature 50: 1 hole(s) preserved Feature 74: 1 hole(s) preserved Total features with holes: 5 All features are Polygon type: True Total features: 100
2:06
The fix is complete. Let me summarize what was done: 1. The problem: All exterior rings were oriented clockwise (CW) and interior rings (holes) were oriented counter-clockwise (CCW). GeoJSON (RFC 7946) requires the opposite — exterior rings must be CCW and holes must be CW. This caused web viewers to misinterpret the interiors: holes appeared filled and exteriors appeared as empty space. 2. The fix: Reversed the coordinate order of each ring that had the wrong orientation: - All 100 exterior rings were reversed from CW to CCW - All 5 interior rings (holes) were reversed from CCW to CW 3. All attributes are untouched, all geometries remain Polygon type with holes preserved, and all geometries are valid.
2:06