87 lines
5.9 KiB
Python
87 lines
5.9 KiB
Python
"""Prepare independently loaded city/county game assets from a GIS snapshot.
|
|
|
|
Requires Shapely only for this preparation step; npm run build uses the saved
|
|
src/county assets and source index and does not require Python GIS packages.
|
|
"""
|
|
import argparse, copy, hashlib, json, math, pathlib
|
|
from shapely import make_valid
|
|
from shapely.geometry import Polygon, MultiPolygon
|
|
from map_geometry import project
|
|
|
|
ROOT=pathlib.Path(__file__).resolve().parent.parent
|
|
|
|
def polygons(geometry):
|
|
if geometry.geom_type=='Polygon':return [geometry]
|
|
if hasattr(geometry,'geoms'):return [p for item in geometry.geoms for p in polygons(item)]
|
|
return []
|
|
|
|
def rounded_ring(ring,digits):
|
|
points=[]
|
|
for p in list(ring.coords)[:-1]:
|
|
q=[round(p[0],digits),round(p[1],digits)]
|
|
q=[int(v) if float(v).is_integer() else v for v in q]
|
|
if not points or q!=points[-1]:points.append(q)
|
|
if len(points)>1 and points[0]==points[-1]:points.pop()
|
|
return points
|
|
|
|
def compact_polygon(poly):
|
|
x1,y1,x2,y2=poly.bounds;span=max(x2-x1,y2-y1);eps=span*.003
|
|
simple=poly.simplify(eps,preserve_topology=True)
|
|
for _ in range(18):
|
|
if len(simple.exterior.coords)<=81:break
|
|
candidate=poly.simplify(eps*1.3,preserve_topology=True)
|
|
if candidate.area<poly.area*.97 or candidate.area>poly.area*1.03:break
|
|
simple=candidate;eps*=1.3
|
|
for digits in [1,2,3,4,6,8]:
|
|
outer=rounded_ring(simple.exterior,digits);holes=[rounded_ring(h,digits) for h in simple.interiors]
|
|
if len(outer)<3 or any(len(h)<3 for h in holes):continue
|
|
candidate=Polygon(outer,holes)
|
|
if candidate.is_valid and candidate.area>0 and abs(candidate.area-simple.area)/simple.area<=.01:
|
|
return outer,holes,digits
|
|
raise ValueError('Cannot quantize a polygon without losing its topology')
|
|
|
|
def main():
|
|
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--snapshot',default='2026-10-18');args=parser.parse_args()
|
|
snapshot=ROOT/'data/city-county-gis'/args.snapshot;manifest=json.loads((snapshot/'manifest.json').read_text())
|
|
dest=ROOT/'src/county';dest.mkdir(exist_ok=True);index={'source':manifest['source'],'snapshot':args.snapshot,'levels':[],'unavailable':[]};catalog=[];total=0;repairs=0
|
|
for entry in manifest['cities']:
|
|
if entry['status']!='available':
|
|
index['unavailable'].append({'id':entry['id'],'name':entry['name'],'province':entry['province'],'reason':'no_county_children'})
|
|
continue
|
|
old=json.loads((snapshot/entry['preparedPath']).read_text());raw=json.loads((snapshot/entry['path']).read_text())
|
|
all_points=[project(p) for f in raw['features'] for poly in (f['geometry']['coordinates'] if f['geometry']['type']=='MultiPolygon' else [f['geometry']['coordinates']]) for p in poly[0]]
|
|
minx,miny=min(p[0] for p in all_points),min(p[1] for p in all_points);span=max(max(p[0] for p in all_points)-minx,max(p[1] for p in all_points)-miny);scale=1000/span
|
|
def local(p):
|
|
q=project(p);return [(q[0]-minx)*scale,(q[1]-miny)*scale]
|
|
level={k:copy.deepcopy(old[k]) for k in ['id','name','province','width','height','edges']}
|
|
level.update(kind='county',parent=entry['province'],provinceName=entry['provinceName'],playLabel='县区拼图',unitCaption='市辖区 · 县 · 县级市',scopeNote='左右调整待落块,松手落下,拼完整片'+entry['name']+'。',regions=[])
|
|
proof=[]
|
|
for feature in raw['features']:
|
|
prop=feature['properties'];source_polys=feature['geometry']['coordinates'] if feature['geometry']['type']=='MultiPolygon' else [feature['geometry']['coordinates']]
|
|
output=[];fixed=0
|
|
for source_poly in source_polys:
|
|
poly=Polygon([local(p) for p in source_poly[0]],[[local(p) for p in h] for h in source_poly[1:]])
|
|
if not poly.is_valid:poly=make_valid(poly);fixed+=1
|
|
pieces=polygons(poly)
|
|
if not pieces:raise ValueError('No polygon after source repair')
|
|
output.extend(pieces)
|
|
output.sort(key=lambda p:p.area,reverse=True);region={'id':str(prop['adcode']),'name':prop['name'],'rings':[]};holes=[]
|
|
for poly in output:
|
|
outer,inner,digits=compact_polygon(poly);region['rings'].append(outer);holes.extend(inner)
|
|
if holes:region['holes']=holes
|
|
center=MultiPolygon(output).centroid;label=output[0].representative_point()
|
|
region['center']=[round(center.x,1),round(center.y,1)];region['label']=[round(label.x,1),round(label.y,1)]
|
|
level['regions'].append(region);repairs+=fixed
|
|
proof.append({'id':region['id'],'name':region['name'],'sourceRings':len(source_polys),'sourceHoles':sum(len(p)-1 for p in source_polys),'rings':len(region['rings']),'holes':len(holes),'repairedSourcePolygons':fixed})
|
|
assert [r['id'] for r in level['regions']]==[r['id'] for r in old['regions']]
|
|
payload=json.dumps(level,ensure_ascii=False,separators=(',',':'))
|
|
(dest/(level['id']+'.js')).write_text('window.PuzzleAtlas.register('+payload+');\n')
|
|
index['levels'].append({'id':level['id'],'name':level['name'],'province':level['province'],'provinceName':entry['provinceName'],'sha256':entry['sha256'],'regions':proof})
|
|
catalog.append({'id':level['id'],'name':level['name'],'province':level['province'],'provinceName':entry['provinceName'],'kind':'county','playLabel':'县区拼图','count':len(level['regions']),'path':'./county/'+level['id']+'.js'})
|
|
total+=len(payload.encode())
|
|
(ROOT/'src/county-index.js').write_text('window.COUNTY_INDEX='+json.dumps({'levels':catalog,'unavailable':index['unavailable']},ensure_ascii=False,separators=(',',':'))+';\n')
|
|
(ROOT/'research/county-map-index.json').write_text(json.dumps(index,ensure_ascii=False,separators=(',',':'))+'\n')
|
|
print('County assets:',len(catalog),'levels;',sum(v['count'] for v in catalog),'pieces;',total,'bytes; source polygon repairs:',repairs)
|
|
|
|
if __name__=='__main__':main()
|