Files
shanhe-puzzle/scripts/prepare-county-levels.py
T
codex-bot 4845826deb
Shanhe source and offline package checks / verify (pull_request) Failing after 5s
feat: sync Shanhe 2.2 source and add remote package verification
2026-11-01 17:13:46 +08:00

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()