Expose the runtime block/item registry through compact material search and single-material descriptions. Preserve private block-entity data through checked edits and durable undo without sending payloads to model context. Include the completed terrain tools, isolated world/map plugin, station lift, ACP streaming and guidance fixes, local camera auto-connect, construction scripts, and their public documentation, references and verification records. Active station decoration and private runtime data remain outside this commit. Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build, and isolated Paper verification of all 1,196 block defaults plus 5,392 independent property cases for placement, same-material edits and restoration.
277 lines
12 KiB
Python
277 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Rasterize the reviewed Shacraft stage-one design without editing the world.
|
|
|
|
The public build_geometry(design, layout) function uses only the Python standard
|
|
library. Coordinate keys are (x, z). Each cell has block_y, kind, group and clear;
|
|
straight bottom stairs additionally have facing. block_y is not player feet Y.
|
|
|
|
Precedence: exact foundation polygons, northeast landing, local roads, central
|
|
station staircase. Existing dense approved centerlines are used for road curves.
|
|
Roads extend a few flat rows back into their origin plaza so an avenue cannot
|
|
pinch down to the one-block vertex of the hexagonal square.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
Point = tuple[int, int]
|
|
Cell = dict[str, Any]
|
|
|
|
|
|
def _on_segment(x: int, z: int, a, b) -> bool:
|
|
cross = (x-a[0])*(b[1]-a[1]) - (z-a[1])*(b[0]-a[0])
|
|
return abs(cross) < 1e-8 and min(a[0],b[0]) <= x <= max(a[0],b[0]) and min(a[1],b[1]) <= z <= max(a[1],b[1])
|
|
|
|
|
|
def polygon_cells(vertices) -> set[Point]:
|
|
"""Integer block columns inside OR on the exact polygon, with no box fill."""
|
|
vertices = [tuple(p) for p in vertices]
|
|
if len(vertices) < 3:
|
|
raise ValueError('A foundation polygon needs three vertices')
|
|
segments = list(zip(vertices, vertices[1:]+vertices[:1]))
|
|
result: set[Point] = set()
|
|
for z in range(math.floor(min(p[1] for p in vertices)), math.ceil(max(p[1] for p in vertices))+1):
|
|
for x in range(math.floor(min(p[0] for p in vertices)), math.ceil(max(p[0] for p in vertices))+1):
|
|
inside = False
|
|
boundary = False
|
|
for a,b in segments:
|
|
if _on_segment(x,z,a,b):
|
|
boundary = True
|
|
break
|
|
if (a[1] > z) != (b[1] > z):
|
|
cross_x = a[0] + (z-a[1])*(b[0]-a[0])/(b[1]-a[1])
|
|
if x < cross_x:
|
|
inside = not inside
|
|
if boundary or inside:
|
|
result.add((x,z))
|
|
return result
|
|
|
|
|
|
def _distance_squared(x: int, z: int, a, b) -> float:
|
|
dx,dz=b[0]-a[0],b[1]-a[1]
|
|
if not dx and not dz:
|
|
return (x-a[0])**2+(z-a[1])**2
|
|
t=max(0.,min(1.,((x-a[0])*dx+(z-a[1])*dz)/(dx*dx+dz*dz)))
|
|
return (x-a[0]-t*dx)**2+(z-a[1]-t*dz)**2
|
|
|
|
|
|
def _densify(points) -> list[Point]:
|
|
result=[]
|
|
for a,b in zip(points,points[1:]):
|
|
n=max(1,math.ceil(max(abs(a[0]-b[0]),abs(a[1]-b[1]))))
|
|
for i in range(n+1):
|
|
p=(round(a[0]+(b[0]-a[0])*i/n),round(a[1]+(b[1]-a[1])*i/n))
|
|
if not result or result[-1] != p:
|
|
result.append(p)
|
|
if not result and points:
|
|
result=[tuple(map(round,points[0]))]
|
|
return result
|
|
|
|
|
|
def _cardinal_path(points: list[Point]) -> list[Point]:
|
|
"""Insert cardinal intermediate samples for collision checks on diagonals."""
|
|
if not points:
|
|
return []
|
|
result=[points[0]]
|
|
for x,z in points[1:]:
|
|
ax,az=result[-1]
|
|
while (ax,az)!=(x,z):
|
|
if ax!=x:
|
|
ax += 1 if x>ax else -1
|
|
else:
|
|
az += 1 if z>az else -1
|
|
result.append((ax,az))
|
|
return result
|
|
|
|
|
|
def _approved_centerline(spec, layout) -> list[Point]:
|
|
base_id=spec['id'].removesuffix('-local')
|
|
approved=next((r for r in layout.get('routes',[]) if r['id']==base_id),None)
|
|
points=_densify(approved['points'] if approved else spec['waypoints'])
|
|
if spec['geometry']=='road_profile':
|
|
axis=0 if spec['axis']=='x' else 1
|
|
lo=min(row['coordinate'] for row in spec['rows'])
|
|
hi=max(row['coordinate'] for row in spec['rows'])
|
|
points=[p for p in points if lo <= p[axis] <= hi]
|
|
# If the approved path terminates just before a reviewed local endpoint,
|
|
# add the small explicit tail without replacing its established curve.
|
|
expected=tuple(spec['waypoints'][-1])
|
|
if points and points[-1][axis] != expected[axis]:
|
|
points += _densify([points[-1],expected])[1:]
|
|
elif 'profile_until_z' in spec:
|
|
stop=spec['profile_until_z']
|
|
points=[p for p in points if p[1]>=stop]
|
|
if len(points)<2:
|
|
raise ValueError(f"No usable centerline for {spec['id']}")
|
|
return points
|
|
|
|
|
|
def _origin_extension(points: list[Point], spec) -> list[Point]:
|
|
"""Overlap the origin plateau without changing its level or approved curve."""
|
|
first=points[0]
|
|
distance=max(4,spec['width']//2+2)
|
|
target=points[min(len(points)-1,distance)]
|
|
dx,dz=target[0]-first[0],target[1]-first[1]
|
|
length=math.hypot(dx,dz)
|
|
if not length:
|
|
return points
|
|
back=(round(first[0]-dx*distance/length),round(first[1]-dz*distance/length))
|
|
return _densify([back,first])[:-1]+points
|
|
|
|
|
|
def _corridor(points: list[Point], width: int, axis=None, lo=None, hi=None):
|
|
"""Clear corridor plus one block of side coping, evaluated by cell centers."""
|
|
outer=width/2+1
|
|
clear_radius2=(width/2)**2
|
|
outer_radius2=outer**2
|
|
distances: dict[Point,float] = {}
|
|
for a,b in zip(points,points[1:]):
|
|
for z in range(math.floor(min(a[1],b[1])-outer),math.ceil(max(a[1],b[1])+outer)+1):
|
|
for x in range(math.floor(min(a[0],b[0])-outer),math.ceil(max(a[0],b[0])+outer)+1):
|
|
if axis is not None and not lo <= (x,z)[axis] <= hi:
|
|
continue
|
|
d=_distance_squared(x,z,a,b)
|
|
if d <= outer_radius2+1e-8 and d < distances.get((x,z),math.inf):
|
|
distances[(x,z)]=d
|
|
return {p:d<=clear_radius2+1e-8 for p,d in distances.items()}
|
|
|
|
|
|
def tread_height(cell: Cell, local_x: float, local_z: float) -> float:
|
|
"""Collision surface of a full block or a straight bottom stair at an offset.
|
|
|
|
Use .25/.75 offsets to inspect both tread halves, avoiding the central edge.
|
|
"""
|
|
if cell['kind']=='full':
|
|
return cell['block_y']+1
|
|
high={'north':local_z<.5,'south':local_z>.5,
|
|
'west':local_x<.5,'east':local_x>.5}[cell['facing']]
|
|
return cell['block_y']+(1. if high else .5)
|
|
|
|
|
|
def build_geometry(design, layout):
|
|
"""Return cells, route metadata and compact invariant checks.
|
|
|
|
cells[(x,z)] -> {block_y:int,kind:'full'|'stairs',facing?:str,
|
|
group:str,clear:bool}
|
|
routes[] -> {id,centerline,centerline_4,corridor,clear_cells,clear_width}
|
|
|
|
`clear` distinguishes usable road paving from exterior coping; for a main
|
|
polygon every floor cell is clear. It does not mean the world has been cleared.
|
|
"""
|
|
cells: dict[Point,Cell] = {}
|
|
routes=[]
|
|
polygon_areas={}
|
|
for surface in design['surfaces']:
|
|
footprint=polygon_cells(surface['points'])
|
|
polygon_areas[surface['id']]=len(footprint)
|
|
for p in footprint:
|
|
cells[p]={'block_y':surface['floor_y'],'kind':'full','group':surface['id'],'clear':True}
|
|
landing=design.get('station_ne_connection')
|
|
if landing:
|
|
for p in polygon_cells(landing['points']):
|
|
cells[p]={'block_y':landing['floor_y'],'kind':'full','group':'station-ne-connection','clear':True}
|
|
for spec in design['roads']:
|
|
centerline=_approved_centerline(spec,layout)
|
|
extended=_origin_extension(centerline,spec)
|
|
profiled=spec['geometry']=='road_profile'
|
|
if profiled:
|
|
axis=0 if spec['axis']=='x' else 1
|
|
profiles={row['coordinate']:row for row in spec['rows']}
|
|
# Extend only the origin; stop exactly at the designed last road row.
|
|
endpoint=centerline[-1][axis]
|
|
origin=extended[0][axis]
|
|
lo,hi=sorted([endpoint,origin])
|
|
corridor=_corridor(extended,spec['width'],axis,lo,hi)
|
|
first=spec['rows'][0]
|
|
else:
|
|
corridor=_corridor(extended,spec['width'])
|
|
# The last flat avenue rows must not cover the station staircase.
|
|
if 'profile_until_z' in spec:
|
|
corridor={p:clear for p,clear in corridor.items() if p[1]>=spec['profile_until_z']}
|
|
for p,clear in corridor.items():
|
|
if profiled:
|
|
row=profiles.get(p[axis])
|
|
if row is None:
|
|
row={'block_y':first['block_y'],'kind':'full'}
|
|
cell={'block_y':row['block_y'],'kind':row['kind'],'group':spec['id'],'clear':clear}
|
|
if row['kind']=='stairs':
|
|
cell['facing']=row['facing']
|
|
else:
|
|
cell={'block_y':spec['floor_y'],'kind':'full','group':spec['id'],'clear':clear}
|
|
# A coping line within an already level clear plaza is a floor band,
|
|
# not an obstacle or a place for a parapet. Preserve that distinction.
|
|
previous=cells.get(p)
|
|
if previous and previous['clear'] and previous['block_y']==cell['block_y'] and previous['kind']==cell['kind']=='full':
|
|
cell['clear']=True
|
|
cells[p]=cell
|
|
routes.append({'id':spec['id'],'centerline':centerline,'centerline_4':_cardinal_path(centerline),'corridor':sorted(corridor),'clear_cells':sorted(p for p,c in corridor.items() if c),'clear_width':spec['width']})
|
|
stair=design['station_entrance_stair']
|
|
stair_cells=[]
|
|
stair_clear=[]
|
|
for row in stair['rows']:
|
|
for x in range(stair['x_min']-1,stair['x_max']+2):
|
|
p=(x,row['z'])
|
|
clear=stair['x_min']<=x<=stair['x_max']
|
|
cells[p]={'block_y':row['block_y'],'kind':row['kind'],'group':'station-entrance-stair','clear':clear}
|
|
if row['kind']=='stairs':
|
|
cells[p]['facing']=row['facing']
|
|
stair_cells.append(p)
|
|
if clear:
|
|
stair_clear.append(p)
|
|
mid=(stair['x_min']+stair['x_max'])//2
|
|
line=[(mid,row['z']) for row in stair['rows']]
|
|
routes.append({'id':'station-entrance-stair','centerline':line,'centerline_4':_cardinal_path(line),'corridor':stair_cells,'clear_cells':stair_clear,'clear_width':stair['width']})
|
|
missing=[]
|
|
blocked=[]
|
|
for route in routes:
|
|
for p in route['centerline_4']:
|
|
if p not in cells:
|
|
missing.append((route['id'],p))
|
|
elif not cells[p]['clear']:
|
|
blocked.append((route['id'],p))
|
|
if missing or blocked:
|
|
raise ValueError(f'Road centerline incomplete: missing={missing[:8]}, non-clear={blocked[:8]}')
|
|
max_step=0.
|
|
for route in routes:
|
|
path=route['centerline_4']
|
|
route_step=0.
|
|
for p,q in zip(path,path[1:]):
|
|
dx,dz=q[0]-p[0],q[1]-p[1]
|
|
departure=tread_height(cells[p],.5+dx*.25,.5+dz*.25)
|
|
arrival=tread_height(cells[q],.5-dx*.25,.5-dz*.25)
|
|
step=abs(departure-arrival)
|
|
if step>.5:
|
|
raise ValueError(f"Unwalkable centerline in {route['id']}: {p}->{q}, {step} blocks")
|
|
route_step=max(route_step,step)
|
|
route['maximum_centerline_step']=route_step
|
|
max_step=max(max_step,route_step)
|
|
counts={}
|
|
for cell in cells.values():
|
|
counts[cell['group']]=counts.get(cell['group'],0)+1
|
|
return {'cells':cells,'routes':routes,'checks':{'columns':len(cells),'polygon_areas':polygon_areas,'columns_by_final_group':counts,'centerline_missing':len(missing),'centerline_nonclear':len(blocked),'maximum_centerline_step':max_step,'stairs':sum(c['kind']=='stairs' for c in cells.values()),'minimum_block_y':min(c['block_y'] for c in cells.values()),'maximum_block_y':max(c['block_y'] for c in cells.values()),'world_edits':0}}
|
|
|
|
|
|
def serializable(geometry):
|
|
return {**geometry,'cells':[{'x':x,'z':z,**cell} for (x,z),cell in sorted(geometry['cells'].items(),key=lambda p:(p[0][1],p[0][0]))]}
|
|
|
|
|
|
def main():
|
|
root=Path(__file__).resolve().parents[2]
|
|
parser=argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--design',type=Path,default=root/'.runtime/foundation-study/design/foundation-design.json')
|
|
parser.add_argument('--layout',type=Path,default=root/'examples/layout/shacraft-lobby-layout.json')
|
|
parser.add_argument('--output',type=Path,default=root/'.runtime/foundation-study/design/geometry.json')
|
|
args=parser.parse_args()
|
|
geometry=build_geometry(json.loads(args.design.read_text()),json.loads(args.layout.read_text()))
|
|
args.output.parent.mkdir(parents=True,exist_ok=True)
|
|
args.output.write_text(json.dumps(serializable(geometry),separators=(',',':'))+'\n')
|
|
print(json.dumps({'output':str(args.output),'checks':geometry['checks']},indent=2))
|
|
|
|
|
|
if __name__=='__main__':
|
|
main()
|