Expand material support and checkpoint the completed building toolkit
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.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only elevation study for the first two Shacraft construction districts.
|
||||
|
||||
Writes a reviewable design specification and sections. Does not issue world edits.
|
||||
Run with .runtime/terrain-study/venv/bin/python scripts/foundation-study/design.py.
|
||||
Y convention: floor_y is the block coordinate; full-block walking height is Y + 1.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.path import Path as Polygon
|
||||
from matplotlib.colors import LightSource
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OUT=ROOT/'.runtime/foundation-study/design'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
layout=json.loads((ROOT/'examples/layout/shacraft-lobby-layout.json').read_text())
|
||||
actual=json.loads((ROOT/'.runtime/server/plugins/ShacraftTerrain/maps/layout-final.json').read_text())
|
||||
natural=np.floor(np.fromfile(ROOT/'.runtime/terrain-study/natural.f32',dtype='>f4').reshape(768,768)).astype(int)
|
||||
live_heights=np.asarray(actual['surface_y']).reshape(768,768)
|
||||
live_materials=np.asarray(actual['material_index']).reshape(768,768)
|
||||
zz,xx=np.mgrid[-384:384,-384:384]
|
||||
points=np.column_stack([xx.ravel(),zz.ravel()])
|
||||
|
||||
def feature(fid): return next(f for f in layout['features'] if f['id']==fid)
|
||||
def route(fid): return next(f for f in layout['routes'] if f['id']==fid)
|
||||
def polygon_mask(vertices):return Polygon(vertices).contains_points(points,radius=.1).reshape(natural.shape)
|
||||
def metrics(mask,y):
|
||||
h=natural[mask]
|
||||
deltas,counts=np.unique(live_heights[mask]-h,return_counts=True)
|
||||
actual_delta_counts={str(int(k)):int(v) for k,v in zip(deltas,counts)}
|
||||
return {'columns':int(mask.sum()),'live_surface_delta_from_natural_counts':actual_delta_counts,'natural_min_y':int(h.min()),'natural_max_y':int(h.max()),'cut_above_floor_blocks':int(np.maximum(h-y,0).sum()),'fill_above_natural_blocks':int(np.maximum(y-h,0).sum()),'maximum_cut':int(max(0,(h-y).max())),'maximum_fill':int(max(0,(y-h).max()))}
|
||||
|
||||
surfaces=[]
|
||||
for fid,y in [('arrival-hex',95),('station-forecourt',95),('clock-station',98)]:
|
||||
f=feature(fid); mask=polygon_mask(f['points'])
|
||||
surfaces.append({'id':fid,'district':f['district'],'geometry':'polygon','points':f['points'],'floor_y':y,'walk_y':y+1,'support':'Solid to existing ground inside the exact footprint; two-deep structural deck in cut areas. Exterior dark stone masonry, light stone coping, no broad earth platform.','metrics':metrics(mask,y)})
|
||||
|
||||
# Each schedule is expressed in the increasing travel coordinate d, irrespective of
|
||||
# whether the selected world axis increases or decreases along travel. A stair
|
||||
# replaces the current high-level full block; its back points toward the higher
|
||||
# previous row. The next row is one full block lower. This guarantees 0.5 steps.
|
||||
def descent(fid,axis,sign,start,end,y,stairs,final_y,width,waypoints):
|
||||
rows=[]; level=y
|
||||
for a in range(start*sign,end*sign+1):
|
||||
c=a*sign
|
||||
is_stair=c in stairs
|
||||
facing=('north' if axis=='z' else ('west' if sign==1 else 'east'))
|
||||
row={'coordinate':c,'block_y':level,'kind':'stairs' if is_stair else 'full','walk_high_y':level+1,'walk_low_y':level+.5 if is_stair else level+1}
|
||||
if is_stair:row['facing']=facing
|
||||
rows.append(row)
|
||||
if is_stair:level-=1
|
||||
assert level==final_y,(fid,level,final_y)
|
||||
# Walking from higher to lower: flat->stair upper edge is same elevation;
|
||||
# stair upper->lower edge is 0.5, lower->next flat is another 0.5.
|
||||
for a,b in zip(rows,rows[1:]):assert abs(a['walk_low_y']-b['walk_high_y'])<=.5
|
||||
return {'id':fid,'geometry':'road_profile','axis':axis,'travel_sign':sign,'width':width,'width_note':'Clear paving width, with one additional coping block outside either edge; use the existing marked centerline tangent.','waypoints':waypoints,'start_floor_y':y,'end_floor_y':final_y,'rows':rows,'max_walk_step':.5,'scope':'Only this local part is built in stage 01; preserve all later district markers.'}
|
||||
|
||||
roads=[
|
||||
{'id':'station-axis','geometry':'road_flat','width':9,'floor_y':95,'walk_y':96,'waypoints':route('station-axis')['waypoints'],'profile_until_z':-77,'intent':'Continuous flat link into the forecourt; ornamental edge strips outside nine clear blocks.'},
|
||||
descent('south-axis-local','z',1,57,110,95,[58,61,64,67,70,73,76,79,82,86,90,94,98,101,104,107],79,9,[[0,57],[3,97],[1,110]]),
|
||||
descent('portal-radial-local','x',-1,-35,-72,95,[-42,-47,-52,-58,-64,-71],89,7,[[-35,-14],[-67,-43],[-72,-46]]),
|
||||
descent('lake-radial-local','x',-1,-42,-90,95,[-44,-47,-50,-55,-64,-72,-78,-83,-85,-87,-89,-90],83,5,[[-42,18],[-77,27],[-90,32]]),
|
||||
descent('east-radial-local','x',1,42,82,95,[43,47,51,55,60,66,72,78],87,7,[[42,16],[65,10],[82,8]]),
|
||||
descent('ring-northwest-local','x',-1,-40,-80,95,[-42,-45,-49,-53,-57,-61,-65,-69,-73,-77],85,7,[[-40,-65],[-77,-76],[-80,-77]]),
|
||||
descent('ring-northeast-local','x',1,40,82,98,[41,44,47,50,54,58,62,66,70,74,78,81],86,7,[[40,-95],[60,-91],[82,-78]])
|
||||
]
|
||||
# Endpoint toes use actual captured surface elevations. At the lake the last
|
||||
# in-bounds column is a stair: a full block here would leave a full-block drop to
|
||||
# the next native row, and editing that row would exceed the stage's X=-90 bound.
|
||||
endpoint_toes=[]
|
||||
for rid,end in [('portal-radial-local',(-72,-46)),('lake-radial-local',(-90,32))]:
|
||||
r=next(item for item in roads if item['id']==rid)
|
||||
last=r['rows'][-1]
|
||||
native=[]
|
||||
for z in range(end[1]-1,end[1]+2):
|
||||
x=end[0]-1
|
||||
native.append({'x':x,'z':z,'natural_block_y':int(natural[z+384,x+384]),'live_surface_y':int(live_heights[z+384,x+384]),'step_from_road':abs(last['walk_low_y']-(int(live_heights[z+384,x+384])+1))})
|
||||
assert all(n['step_from_road']<=.5 for n in native),(rid,native)
|
||||
r['endpoint_kind']=last['kind']
|
||||
r['endpoint_block_y']=last['block_y']
|
||||
r['endpoint_walk_low_y']=last['walk_low_y']
|
||||
r['native_toe']=native
|
||||
endpoint_toes.append({'id':rid,'last_road_column':list(end),'last_road_kind':last['kind'],'last_road_block_y':last['block_y'],'native_front_three':native})
|
||||
stairs=[
|
||||
{'z':-77,'block_y':95,'kind':'full'},
|
||||
{'z':-78,'block_y':96,'kind':'stairs','facing':'north'},
|
||||
{'z':-79,'block_y':96,'kind':'full'},
|
||||
{'z':-80,'block_y':97,'kind':'stairs','facing':'north'},
|
||||
{'z':-81,'block_y':97,'kind':'full'},
|
||||
{'z':-82,'block_y':98,'kind':'stairs','facing':'north'},
|
||||
{'z':-83,'block_y':98,'kind':'full'}]
|
||||
plan={
|
||||
'schema':'shacraft-foundation-elevation-design-v1',
|
||||
'status':'DESIGN ONLY; root builder must compare actual voxels before applying and verify final live world.',
|
||||
'world':actual['world'],'source_capture_finished_at':actual['capture_finished_at'],
|
||||
'y_convention':'floor_y/block_y is the Minecraft block coordinate. A full block at Y95 is walked on at Y96. A bottom stair at Y96 spans feet heights96.5..97.',
|
||||
'selected_districts':['01','02'],
|
||||
'surfaces':surfaces,'roads':roads,'verified_native_endpoint_toes':endpoint_toes,
|
||||
'station_entrance_stair':{'x_min':-16,'x_max':4,'width':21,'travel_direction':'north','rows':stairs,'max_walk_step':.5},
|
||||
'station_ne_connection':{'geometry':'polygon','points':[[35,-103],[42,-103],[44,-95],[42,-91],[35,-94],[35,-103]],'floor_y':98,'intent':'Small local upper landing joins east wing to the northeast descending road. Keep inside this polygon; no platform around the whole station.'},
|
||||
'plaza_inlay':{'center':[0,9],'outer_radius':16,'floor_y':95,'intent':'Flat green hexagonal ring with cream Shacraft S inlay. Reserve the core for later fountain/arrival feature if desired; never raise the inlay above paving.'},
|
||||
'masonry':{'foundation_core':'minecraft:stone','dark_footing':'minecraft:deepslate_bricks','wall':'minecraft:stone_bricks','secondary_wall':'minecraft:andesite','light_coping':'minecraft:smooth_sandstone','paving':'minecraft:smooth_sandstone','paving_bands':'minecraft:smooth_stone','accent':'minecraft:green_concrete','notes':['Do not fill the surrounding rectangle. Shape all exposed plinth walls to the exact existing footprint.','The west station wing has up to16 blocks of foundation. Use vertical pilasters every8 blocks and recessed blind arch panels; one plain flat wall would look excessive.','Leave district02 building floor usable and flat. Footing lines can indicate tower and pavilions; no tall unfinished walls across doors.','Put parapets only on exposed drops, outside the clear walking width; leave all route openings unblocked.','Finish side road ends with a full-width landing; east87/northeast86 match the existing future bridge levels, so keep the boundary to those markers precise.']},
|
||||
'quality_checks':['Actual block-for-block scan of finished footprint and road surfaces.','Cardinal-direction walking graph from spawn to station floor and every road endpoint, including stair orientation and at least2 air blocks headroom.','Every descent has half-block treads; never create a full block jump as a path transition.','Foundation columns extend down to solid existing ground; no hidden unsupported floating perimeter.','No new block in a water column; no edit outside foundation/road footprints except explicitly approved local rail, light and footing cells.','Check road width across diagonal curves; evaluate the union of row footprints rather than nearest centerline points alone.','Remove obsolete colored markers and text only inside the stage01 mutation envelope; preserve all other district reservations.','Compare orthographic live surface map with plan and inspect actual west station foundation + north axis from player height.'],
|
||||
'endpoint_join_notes':['South road ends at z110/block79 near actual ground78..80; a few local grading cells or one final stair row may be required after live voxel inspection.','Portal endpoint(-72,-46) fullY89 joins native89. Lake endpoint(-90,32) uses terminal east-facing stairY84, joining native83 beyond x-90 across the center3 cells with half-block steps; never replace this terminal stair with a full-block landing.','Northeast road must originate at the station upper landing98, not the forecourt95.','The existing camera is spectator; physical walking correctness still requires geometric collision checks.']
|
||||
}
|
||||
(OUT/'foundation-design.json').write_text(json.dumps(plan,indent=2)+'\n')
|
||||
|
||||
# Inspectable plan/sections are computed from immutable natural heights.
|
||||
# Live surface deltas are measured separately in the JSON metrics above.
|
||||
fig=plt.figure(figsize=(15,11),layout='constrained')
|
||||
gs=fig.add_gridspec(2,2,width_ratios=[1.22,1],height_ratios=[1,1])
|
||||
ax=fig.add_subplot(gs[:,0]); extent=(-110,105,125,-165)
|
||||
h=natural[219:510,274:490]
|
||||
shade=LightSource(315,40).hillshade(h,vert_exag=2,dx=1,dy=1)
|
||||
ax.imshow(h,cmap='gist_earth',extent=extent,alpha=.8,vmin=48,vmax=150)
|
||||
ax.imshow(shade,cmap='gray',extent=extent,alpha=.2)
|
||||
colors=['#f2dfbd','#ddd2b6','#f0c75b']
|
||||
for s,c in zip(surfaces,colors):
|
||||
pp=np.array(s['points']); ax.fill(pp[:,0],pp[:,1],c,alpha=.8);ax.plot(pp[:,0],pp[:,1],color='#263e39',lw=1.2)
|
||||
cc=pp.mean(axis=0);ax.text(cc[0],cc[1],s['id'].replace('-',' ')+'\nblock Y'+str(s['floor_y']),ha='center',va='center',fontsize=9,bbox=dict(facecolor='white',alpha=.8,edgecolor='none'))
|
||||
for r in roads:
|
||||
p=np.array(r['waypoints']);ax.plot(p[:,0],p[:,1],color='#ede3cf',lw=r['width']*.55,solid_capstyle='round');ax.plot(p[:,0],p[:,1],color='#485b53',lw=.6)
|
||||
if 'end_floor_y' in r:ax.text(p[-1,0],p[-1,1],'Y'+str(r.get('endpoint_block_y',r['end_floor_y']))+(' stair' if r.get('endpoint_kind')=='stairs' else ''),fontsize=8,ha='center',va='bottom',bbox=dict(facecolor='white',alpha=.85,edgecolor='none'))
|
||||
ax.set(xlim=(-105,100),ylim=(125,-165),xlabel='X — east →',ylabel='Z — south →',title='Shacraft · stage 01 foundations and local streets')
|
||||
ax.set_aspect('equal');ax.grid(alpha=.15)
|
||||
ax2=fig.add_subplot(gs[0,1]);xs=np.arange(-80,47); zs=-120*np.ones_like(xs); ground=natural[zs+384,xs+384]
|
||||
ax2.fill_between(xs,ground,70,color='#718356',alpha=.65,label='Existing natural ground'); ax2.plot(xs,ground,color='#354d2c',lw=1)
|
||||
inside=(xs>=-74)&(xs<=40);ax2.plot(xs[inside],np.full(sum(inside),99),color='#ae7731',lw=2,label='Station walking plane Y99');ax2.fill_between(xs[inside],ground[inside],99,color='#c4bda9',alpha=.6,label='Supported station plinth')
|
||||
ax2.set(xlabel='X across station at Z−120',ylabel='Y elevation',ylim=(78,103),title='Station: one continuous floor; articulated west retaining base');ax2.legend(fontsize=8,loc='lower right');ax2.grid(alpha=.2)
|
||||
ax3=fig.add_subplot(gs[1,1]);zvals=np.arange(-100,111);xvals=np.where(zvals<0,-6,0);ground=natural[zvals+384,xvals+384];ax3.fill_between(zvals,ground,65,color='#718356',alpha=.65)
|
||||
walk=np.full(len(zvals),96.);walk[zvals<=-83]=99
|
||||
for row in stairs:
|
||||
k=np.where(zvals==row['z'])[0][0];walk[k]=row['block_y']+(0.75 if row['kind']=='stairs' else 1)
|
||||
for row in roads[1]['rows']:
|
||||
k=np.where(zvals==row['coordinate'])[0][0];walk[k]=(row['walk_low_y']+row['walk_high_y'])/2
|
||||
ax3.plot(zvals,walk,color='#a47230',lw=2,label='Planned walking surface');ax3.plot(zvals,ground,color='#354d2c',lw=1,label='Natural centerline')
|
||||
ax3.set(xlabel='Z along north/south arrival route',ylabel='Y elevation',ylim=(74,103),title='Continuous arrival sequence: station → forecourt → square → approach');ax3.grid(alpha=.2);ax3.legend(fontsize=8)
|
||||
fig.savefig(OUT/'foundation-plan-and-sections.png',dpi=170)
|
||||
print(json.dumps({'plan':str(OUT/'foundation-design.json'),'figure':str(OUT/'foundation-plan-and-sections.png'),'surfaces':[{k:v for k,v in s.items() if k in ['id','floor_y','metrics']} for s in surfaces]},indent=2))
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independently audit planned foundation navigation on a half-block surface grid.
|
||||
|
||||
This is geometry QA, not a Minecraft collision simulator or a live block survey.
|
||||
Only clear cells are traversable. Four quarter-center samples describe each full
|
||||
block or straight bottom stair; adjacent samples require <= 0.5-block height change.
|
||||
"""
|
||||
import argparse
|
||||
from collections import Counter, deque
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
def normalize_cells(document):
|
||||
result = {}
|
||||
for cell in document['cells']:
|
||||
if any(type(cell.get(key)) is not int for key in ('x', 'z', 'block_y')):
|
||||
raise ValueError('Geometry coordinates and block_y must be integers')
|
||||
if type(cell.get('clear')) is not bool or cell.get('kind') not in ('full', 'stairs'):
|
||||
raise ValueError('Geometry needs explicit clear and full/stairs kind')
|
||||
if cell['kind'] == 'stairs' and cell.get('facing') not in ('north', 'east', 'south', 'west'):
|
||||
raise ValueError('Stairs need an explicit cardinal facing')
|
||||
at = (cell['x'], cell['z'])
|
||||
if at in result:
|
||||
raise ValueError(f'Duplicate geometry cell {at}')
|
||||
result[at] = cell
|
||||
return result
|
||||
|
||||
|
||||
def cell_nodes(x, z):
|
||||
return [(2 * x + i, 2 * z + j) for i in (0, 1) for j in (0, 1)]
|
||||
|
||||
|
||||
def height2(cell, sample):
|
||||
if cell['kind'] == 'full':
|
||||
return 2 * cell['block_y'] + 2
|
||||
ix, iz = sample[0] % 2, sample[1] % 2
|
||||
facing = cell['facing']
|
||||
high = ((facing == 'north' and iz == 0) or (facing == 'south' and iz == 1)
|
||||
or (facing == 'west' and ix == 0) or (facing == 'east' and ix == 1))
|
||||
return 2 * cell['block_y'] + (2 if high else 1)
|
||||
|
||||
|
||||
def surface_graph(cells):
|
||||
return {node: height2(cell, node) for (x, z), cell in cells.items() if cell['clear']
|
||||
for node in cell_nodes(x, z)}
|
||||
|
||||
|
||||
def neighbors(node):
|
||||
x, z = node
|
||||
return ((x - 1, z), (x + 1, z), (x, z - 1), (x, z + 1))
|
||||
|
||||
|
||||
def reachable(graph, source):
|
||||
starts = [p for p in cell_nodes(*source) if p in graph]
|
||||
if len(starts) != 4:
|
||||
raise ValueError(f'Navigation source {source} is missing or non-clear')
|
||||
# One source prevents accidentally joining two disconnected halves of a cell.
|
||||
queue, reached = deque(starts[:1]), {starts[0]: 0}
|
||||
while queue:
|
||||
node = queue.popleft()
|
||||
for other in neighbors(node):
|
||||
if other in graph and other not in reached and abs(graph[node] - graph[other]) <= 1:
|
||||
reached[other] = reached[node] + 1
|
||||
queue.append(other)
|
||||
return reached
|
||||
|
||||
|
||||
def at_node(node, height=None):
|
||||
value = {'x': node[0] / 2 + .25, 'z': node[1] / 2 + .25}
|
||||
if height is not None:
|
||||
value['standing_y'] = height / 2
|
||||
return value
|
||||
|
||||
|
||||
def endpoint_report(name, at, graph, reached):
|
||||
nodes = cell_nodes(*at)
|
||||
existing = [p for p in nodes if p in graph]
|
||||
connected = [p for p in nodes if p in reached]
|
||||
return {'name': name, 'x': at[0], 'z': at[1], 'surface_samples': len(existing),
|
||||
'reachable_samples': len(connected), 'passed': len(connected) == 4,
|
||||
'shortest_surface_route_blocks': min((reached[p] / 2 for p in connected), default=None)}
|
||||
|
||||
|
||||
def route_report(route, cells, graph, reached):
|
||||
path = [tuple(p) for p in route.get('centerline_4', route['centerline'])]
|
||||
corridor = {tuple(p) for p in route['corridor']}
|
||||
clear = {tuple(p) for p in route['clear_cells']}
|
||||
width = route['clear_width']
|
||||
if type(width) is not int or width < 1 or width % 2 != 1:
|
||||
raise ValueError('This cross-section audit requires positive odd clear_width')
|
||||
missing_corridor = sorted(corridor - cells.keys())
|
||||
nonclear = sorted(p for p in clear if p not in cells or not cells[p]['clear'])
|
||||
unreachable = sorted(p for p in clear if any(n not in reached for n in cell_nodes(*p)))
|
||||
jumps, invalid_steps = [], []
|
||||
max_boundary_step = 0
|
||||
for p, q in zip(path, path[1:]):
|
||||
dx, dz = q[0] - p[0], q[1] - p[1]
|
||||
if abs(dx) + abs(dz) != 1:
|
||||
invalid_steps.append({'from': list(p), 'to': list(q)})
|
||||
continue
|
||||
# Check both parallel quarter-center lanes across the shared block face.
|
||||
for a in cell_nodes(*p):
|
||||
b = (a[0] + dx, a[1] + dz)
|
||||
if (b[0] // 2, b[1] // 2) != q:
|
||||
continue
|
||||
if a not in graph or b not in graph:
|
||||
jumps.append({'from': at_node(a), 'to': at_node(b), 'reason': 'missing_clear_surface'})
|
||||
continue
|
||||
step = abs(graph[a] - graph[b]) / 2
|
||||
max_boundary_step = max(max_boundary_step, step)
|
||||
if step > .5:
|
||||
jumps.append({'from': at_node(a, graph[a]), 'to': at_node(b, graph[b]),
|
||||
'height_change': step, 'reason': 'height_step_exceeds_half_block'})
|
||||
cross_sections, narrow = [], []
|
||||
for i, p in enumerate(path):
|
||||
# Use at least one corridor-width of tangent support. Inserted cardinal
|
||||
# elbows near an axis-clipped endpoint must not rotate the cross-section
|
||||
# to measure longitudinally past that deliberate terminal boundary.
|
||||
window = max(6, width)
|
||||
before, after = path[max(0, i - window)], path[min(len(path) - 1, i + window)]
|
||||
tx, tz = after[0] - before[0], after[1] - before[1]
|
||||
normal = (0, 1) if abs(tx) > abs(tz) else (1, 0)
|
||||
radius = width // 2
|
||||
samples = [(p[0] + normal[0] * offset, p[1] + normal[1] * offset)
|
||||
for offset in range(-radius, radius + 1)]
|
||||
present = sum(s in cells for s in samples)
|
||||
usable = sum(s in cells and cells[s]['clear'] for s in samples)
|
||||
connected = sum(all(n in reached for n in cell_nodes(*s)) for s in samples)
|
||||
cross_sections.append((present, usable, connected))
|
||||
if min(present, usable, connected) < width:
|
||||
narrow.append({'x': p[0], 'z': p[1], 'normal': list(normal), 'required_width': width,
|
||||
'structural_columns': present, 'clear_columns': usable,
|
||||
'reachable_columns': connected,
|
||||
'problem_columns': [list(s) for s in samples if s not in cells or not cells[s]['clear']
|
||||
or any(n not in reached for n in cell_nodes(*s))]})
|
||||
ends = [endpoint_report(route['id'] + ':' + name, at, graph, reached)
|
||||
for name, at in [('start', path[0]), ('end', path[-1])]]
|
||||
passed = not (missing_corridor or nonclear or unreachable or jumps or invalid_steps or narrow)
|
||||
return {'id': route['id'], 'passed': passed, 'declared_clear_width': width,
|
||||
'corridor_columns': len(corridor), 'missing_corridor_columns': missing_corridor,
|
||||
'declared_clear_columns': len(clear), 'nonclear_declared_columns': nonclear,
|
||||
'unreachable_declared_columns': unreachable,
|
||||
'cross_sections': len(cross_sections),
|
||||
'minimum_structural_cross_section': min(row[0] for row in cross_sections),
|
||||
'minimum_clear_cross_section': min(row[1] for row in cross_sections),
|
||||
'minimum_reachable_cross_section': min(row[2] for row in cross_sections),
|
||||
'narrow_cross_sections': narrow, 'centerline_invalid_steps': invalid_steps,
|
||||
'centerline_maximum_boundary_step': max_boundary_step,
|
||||
'centerline_jumps': jumps, 'endpoints': ends}
|
||||
|
||||
|
||||
def audit(document, source=(0, 9), station=(-6, -105)):
|
||||
cells = normalize_cells(document)
|
||||
graph = surface_graph(cells)
|
||||
reached = reachable(graph, source)
|
||||
unreachable = sorted(p for p, cell in cells.items() if cell['clear']
|
||||
and any(n not in reached for n in cell_nodes(*p)))
|
||||
routes = [route_report(route, cells, graph, reached) for route in document['routes']]
|
||||
goal = endpoint_report('clock-station', station, graph, reached)
|
||||
return {'version': 1, 'source': {'x': source[0], 'z': source[1]}, 'world_edits': 0,
|
||||
'method': 'Four quarter-center surface samples per clear cell; cardinal half-block BFS, rise/drop <= 0.5 block.',
|
||||
'limitations': 'Planned surface topology only. No headroom, body-width collision, material state or live-world verification. Width checks are cardinal cross-sections using the dominant local tangent; full declared masks are also checked.',
|
||||
'geometry_sha256': hashlib.sha256(json.dumps(document, sort_keys=True, separators=(',', ':')).encode()).hexdigest(),
|
||||
'planned_columns': len(cells), 'clear_columns': sum(c['clear'] for c in cells.values()),
|
||||
'surface_samples': len(graph), 'reachable_samples': len(reached),
|
||||
'unreachable_clear_columns': [list(p) for p in unreachable],
|
||||
'unreachable_by_group': dict(Counter(cells[p].get('group', 'unknown') for p in unreachable)),
|
||||
'station': goal, 'routes': routes,
|
||||
'passed': not unreachable and goal['passed'] and all(r['passed'] for r in routes)}
|
||||
|
||||
|
||||
class GeometryAuditTests(unittest.TestCase):
|
||||
def test_full_block_jump_separates_components(self):
|
||||
cells = {(x, 0): {'kind': 'full', 'block_y': 10 if x == 0 else 11, 'clear': True}
|
||||
for x in range(2)}
|
||||
graph = surface_graph(cells)
|
||||
self.assertEqual(len(reachable(graph, (0, 0))), 4)
|
||||
|
||||
def test_stairs_connect_two_levels_for_all_directions(self):
|
||||
for facing, direction in [('north', (0, -1)), ('south', (0, 1)), ('west', (-1, 0)), ('east', (1, 0))]:
|
||||
with self.subTest(facing=facing):
|
||||
dx, dz = direction
|
||||
cells = {(-dx, -dz): {'kind': 'full', 'block_y': 9, 'clear': True},
|
||||
(0, 0): {'kind': 'stairs', 'block_y': 10, 'facing': facing, 'clear': True},
|
||||
(dx, dz): {'kind': 'full', 'block_y': 10, 'clear': True}}
|
||||
graph = surface_graph(cells)
|
||||
self.assertEqual(len(reachable(graph, (-dx, -dz))), 12)
|
||||
|
||||
def test_nonclear_surface_is_never_traversable(self):
|
||||
graph = surface_graph({(0, 0): {'kind': 'full', 'block_y': 10, 'clear': False}})
|
||||
self.assertFalse(graph)
|
||||
|
||||
def test_missing_width_column_is_reported(self):
|
||||
cells = {(x, z): {'x': x, 'z': z, 'kind': 'full', 'block_y': 10, 'clear': True}
|
||||
for x in range(-1, 2) for z in range(3)}
|
||||
del cells[(-1, 1)]
|
||||
graph = surface_graph(cells)
|
||||
route = {'id': 'test', 'centerline': [(0, 0), (0, 1), (0, 2)],
|
||||
'corridor': list(cells), 'clear_cells': list(cells), 'clear_width': 3}
|
||||
report = route_report(route, cells, graph, reachable(graph, (0, 0)))
|
||||
self.assertFalse(report['passed'])
|
||||
self.assertEqual(report['minimum_structural_cross_section'], 2)
|
||||
self.assertEqual(report['narrow_cross_sections'][0]['problem_columns'], [[-1, 1]])
|
||||
|
||||
|
||||
def main():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--geometry', type=Path, default=root / '.runtime/foundation-study/design/geometry.json')
|
||||
parser.add_argument('--report', type=Path, default=root / '.runtime/foundation-study/design/navigation-qa.json')
|
||||
parser.add_argument('--source', type=int, nargs=2, default=(0, 9), metavar=('X', 'Z'))
|
||||
parser.add_argument('--station', type=int, nargs=2, default=(-6, -105), metavar=('X', 'Z'))
|
||||
parser.add_argument('--self-test', action='store_true')
|
||||
args = parser.parse_args()
|
||||
if args.self_test:
|
||||
result = unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(GeometryAuditTests))
|
||||
raise SystemExit(0 if result.wasSuccessful() else 1)
|
||||
report = audit(json.loads(args.geometry.read_text()), tuple(args.source), tuple(args.station))
|
||||
args.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.report.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(json.dumps({'passed': report['passed'], 'clear_columns': report['clear_columns'],
|
||||
'reachable_samples': report['reachable_samples'], 'surface_samples': report['surface_samples'],
|
||||
'unreachable_clear_columns': len(report['unreachable_clear_columns']),
|
||||
'unreachable_by_group': report['unreachable_by_group'], 'station': report['station'],
|
||||
'routes': [{'id': r['id'], 'passed': r['passed'],
|
||||
'minimum_clear_width': r['minimum_clear_cross_section'],
|
||||
'narrow_sections': len(r['narrow_cross_sections']),
|
||||
'centerline_jumps': len(r['centerline_jumps']),
|
||||
'unreachable_columns': len(r['unreachable_declared_columns'])} for r in report['routes']],
|
||||
'report': str(args.report.resolve())}, indent=2))
|
||||
raise SystemExit(0 if report['passed'] else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user