diff --git a/pierzainer_ground/pierzainer_ground/survey_terrain.py b/pierzainer_ground/pierzainer_ground/survey_terrain.py index 26eec2e..1f8f3f0 100644 --- a/pierzainer_ground/pierzainer_ground/survey_terrain.py +++ b/pierzainer_ground/pierzainer_ground/survey_terrain.py @@ -12,6 +12,8 @@ import numpy as np from ezdxf import recover from pyproj import Transformer from pierzainer_ground.dxf_terrain import _densify,read_source_document +from pierzainer_ground.config import GroundConfig +from pierzainer_ground.dxf_terrain import read_terrain_dxf from pierzainer_ground.dtm import DTM ROOT=Path(__file__).resolve().parent.parent @@ -63,6 +65,33 @@ def read_survey_block(source, project, doc=None): block=doc.blocks.get(project['terrain_block']) if block is None: raise ValueError(f"측량 블록이 없습니다: {project['terrain_block']}") + # A plan's modelspace mixes contours with borehole symbols and design + # outlines that also carry Z. Prefer the established semantic reader when + # this drawing declares known survey layers; retain the block reader for + # original survey XREFs with their own layer conventions. + cfg=GroundConfig(cad_axes=project.get('cad_axes','YX'),use_profile_points=False, + z_min=project.get('z_min',0),z_max=project.get('z_max',200), + contour_step=project['contour_step_m'],dedup_xy=project['dedup_m']) + allowed=cfg.layers.spot_block+cfg.layers.spot_text+cfg.layers.contour + if project.get('layers'): + allowed=tuple(layer for layer in allowed if layer in project['layers']) + if (block.name.lower()=='*model_space' and project.get('prefer_recognized_layers',True) + and any(e.dxf.layer in allowed for e in block)): + cfg.allowed_terrain_layers=allowed + terrain=read_terrain_dxf(source,cfg,document=doc) + a=terrain.points + if project.get('exclude_zero'): + a=a[np.abs(a[:,2])>1e-6] + if len(a)<3: + raise ValueError('인식된 측량 레이어에서 실제 표고점·등고선을 3점 이상 읽지 못했습니다.') + if project.get('terrain_crs') and project.get('crs'): + east,north=Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True).transform(a[:,1],a[:,0]) + a=np.column_stack([north,east,a[:,2]]) + if not np.isfinite(a).all(): + raise ValueError('측량 레이어 좌표 변환 결과가 유효하지 않습니다.') + sources=terrain.stats['by_source'] + return a,{'semantic_layer_filter':1,'survey_points':sources['spot'], + 'spot_text_points':sources['text'],'contour_points':sources['contour']},terrain.stats['contours'] to_model=(Transformer.from_crs(project['terrain_crs'],project['crs'],always_xy=True) if project.get('terrain_crs') and project.get('crs') else None) points=[]; contours=[]; types=Counter() diff --git a/pierzainer_ground/tests/test_survey_polyline.py b/pierzainer_ground/tests/test_survey_polyline.py index cd9e740..8ee1656 100644 --- a/pierzainer_ground/tests/test_survey_polyline.py +++ b/pierzainer_ground/tests/test_survey_polyline.py @@ -44,4 +44,27 @@ class SurveyPolylineTests(unittest.TestCase): points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc) self.assertNotIn('rejected_height_outliers',counts);self.assertEqual(points[:,2].max(),84) + def test_known_plan_layers_exclude_near_height_bore_and_base(self): + doc=ezdxf.new();msp=doc.modelspace() + for y,z in [(0,30),(100,32)]: + msp.add_lwpolyline([(0,y),(100,y)],dxfattribs={'layer':'CAA001','elevation':z}) + doc.blocks.new('SPOT').add_circle((0,0),.1) + msp.add_blockref('SPOT',(20,50,31),dxfattribs={'layer':'CA002'}) + msp.add_text('30.5',dxfattribs={'layer':'CA0021','insert':(80,50)}) + for layer in ['CV-BORE-SYMB','CZ-BASE','CF-BRDG-NEWC']: + msp.add_lwpolyline([(45,45),(55,45),(55,55)],dxfattribs={'layer':layer,'elevation':27.5}) + points,counts,_=read_survey_block(Path('unused.dxf'),self.options(),doc=doc) + self.assertGreaterEqual(points[:,2].min(),30) + self.assertEqual(counts['semantic_layer_filter'],1) + self.assertEqual(counts['survey_points'],1) + self.assertEqual(counts['spot_text_points'],1) + self.assertTrue(any(np.array_equal(p,[50,20,31]) for p in points)) + self.assertTrue(any(np.array_equal(p,[50,80,30.5]) for p in points)) + + def test_known_layers_do_not_fall_back_to_symbols_when_empty(self): + doc=ezdxf.new();msp=doc.modelspace() + msp.add_lwpolyline([(0,0),(10,0)],dxfattribs={'layer':'CAA001','elevation':0}) + msp.add_lwpolyline([(0,0),(10,0),(10,10)],dxfattribs={'layer':'CV-BORE-SYMB','elevation':28}) + with self.assertRaises(ValueError):read_survey_block(Path('unused.dxf'),self.options(),doc=doc) + if __name__=='__main__':unittest.main() diff --git a/scripts/regenerate-ungok-terrain.py b/scripts/regenerate-ungok-terrain.py new file mode 100644 index 0000000..a7b6943 --- /dev/null +++ b/scripts/regenerate-ungok-terrain.py @@ -0,0 +1,51 @@ +"""Regenerate the user's two inputs in a separate folder, reusing exact caches.""" +import hashlib +import json +import shutil +import sys +from pathlib import Path +import numpy as np + +root=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(root/'pierzainer_ground')) +from pierzainer_ground.drawing_job import DrawingJob,build_drawing + +old=root/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db' +target=root/'pierzainer_ground/data/output/ungok_corrected_20261110' +evidence=root/'.verify/ungok-terrain-fixed-20261110' +evidence.mkdir(parents=True,exist_ok=True) +settings=json.loads((old/'generation_settings.json').read_text()) +old_file=old/'ground_model_3d_with_maps.json' +sources=[Path(settings['drawing']),Path(settings['piers']),old_file] +digest=lambda p:hashlib.sha256(p.read_bytes()).hexdigest() +before={str(p):digest(p) for p in sources} +if (target/'ground_model_3d_with_maps.json').exists(): + raise SystemExit('Corrected result already exists; preserve it rather than overwrite.') +(target/'cache').mkdir(parents=True,exist_ok=True) +for path in (old/'cache').iterdir(): + if path.name.endswith(('.dxf','.dxf.source.json')) or path.name=='map_layers.json': + shutil.copyfile(path,target/'cache'/path.name) +settings.update(output=str(target),allow_download=False) +log=[] +data=build_drawing(DrawingJob(**settings),lambda text:(log.append(text),print(text,flush=True))) +original=json.loads(old_file.read_text()) +def metrics(d): + g=d['terrain_grid'];z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx']) + edges=np.concatenate([np.abs(np.diff(z,axis=0)).ravel(),np.abs(np.diff(z,axis=1)).ravel()]);edges=edges[np.isfinite(edges)] + return {'valid_nodes':int(np.isfinite(z).sum()),'min_el':float(np.nanmin(z)),'max_el':float(np.nanmax(z)), + 'max_adjacent_dz':float(edges.max()),'edges_over_5m':int((edges>5).sum()),'edges_over_8m':int((edges>8).sum())} +checks={ + 'original_inputs_and_previous_result_unchanged':all(digest(p)==before[str(p)] for p in sources), + 'all_16_centers_have_ground':len(data['piers'])==16 and all(p['ground']['center'] is not None for p in data['piers']), + 'same_structure_ids_and_coordinates':[(p['id'],p['X'],p['Y']) for p in data['piers']]==[(p['id'],p['X'],p['Y']) for p in original['piers']], + 'same_grid_layout':all(data['terrain_grid'][k]==original['terrain_grid'][k] for k in ('origin','step','nx','ny')), + 'semantic_source_filter_used':data['terrain']['source_counts'].get('semantic_layer_filter')==1, + 'no_adjacent_jumps_over_5m':metrics(data)['edges_over_5m']==0, + 'both_embedded_maps_retained':data['map_status']['status']=='mapped' and {l['id'] for l in data['visualization']['layers']}=={'satellite','ground'}, + 'original_source_provenance_matches':data['surface_provenance']['sha256']==before[str(sources[0])] and data['surface_provenance']['pier_sha256']==before[str(sources[1])], +} +report={'result':'PASS' if all(checks.values()) else 'FAIL','checks':checks,'before':metrics(original),'after':metrics(data),'output':str(target/'ground_model_3d_with_maps.json'),'inputs_sha256':before,'terrain':data['terrain'],'log':log, + 'centers':[{'id':new['id'],'before':prior['ground']['center'],'after':new['ground']['center']} for prior,new in zip(original['piers'],data['piers'])]} +(evidence/'report.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)) +print(json.dumps(report,ensure_ascii=False)) +if not all(checks.values()):raise SystemExit(1) diff --git a/scripts/review-ungok-terrain.py b/scripts/review-ungok-terrain.py new file mode 100644 index 0000000..94bb924 --- /dev/null +++ b/scripts/review-ungok-terrain.py @@ -0,0 +1,102 @@ +"""Read-only terrain diagnostics: grid slopes, isolated peaks, and source TIN.""" +import json +import sys +from pathlib import Path +import numpy as np +from collections import Counter + +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT/'pierzainer_ground')) +from pierzainer_ground.survey_terrain import read_survey_block +from pierzainer_ground.dxf_terrain import read_source_document, read_terrain_dxf +from pierzainer_ground.dtm import DTM +from pierzainer_ground.config import GroundConfig + +out=ROOT/'.verify/ungok-terrain-review-20261110' +out.mkdir(parents=True,exist_ok=True) +folder=ROOT/'pierzainer_ground/data/output/app_b614afc0-6829-4aef-96e3-3ff1365ff5db' +data=json.loads((folder/'ground_model_3d_with_maps.json').read_text()) +settings=json.loads((folder/'generation_settings.json').read_text()) +g=data['terrain_grid']; z=np.array([np.nan if v is None else v for v in g['z']]).reshape(g['ny'],g['nx']) +edges=[] +for j in range(g['ny']): + for i in range(g['nx']): + for dj,di in [(0,1),(1,0)]: + if j+dj5 for e in edges),'edges_over_8m':sum(e['dz']>8 for e in edges),'worst_edges':edges[:12]} +source=next((folder/'cache').glob('*.dxf')) +doc,_=read_source_document(source) +cfg=GroundConfig() +points,counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'prefer_recognized_layers':False},doc=doc) +lo=np.array(g['origin'])-settings['max_edge'];hi=np.array(g['origin'])+[(g['nx']-1)*g['step'],(g['ny']-1)*g['step']]+settings['max_edge'] +points=points[np.all((points[:,:2]>=lo)&(points[:,:2]<=hi),axis=1)] +dtm=DTM(points,settings['max_edge']) +summary['source_counts']=counts +summary['raw_height_layers']=dict(Counter(e.dxf.layer for e in doc.blocks.get(settings['terrain_block']) if e.dxftype() in ('POLYLINE','LWPOLYLINE'))) +# Counterfactual only: use the drawing's two contour layers, without replacing +# the generated user model or changing source geometry. +contours,contour_counts,_=read_survey_block(source,{'terrain_block':settings['terrain_block'],'terrain_crs':settings['terrain_crs'],'crs':settings['crs'],'cad_axes':settings['cad_axes'],'contour_step_m':cfg.contour_step,'dedup_m':cfg.dedup_xy,'z_min':settings['z_min'],'z_max':settings['z_max'],'exclude_zero':True,'layers':['CAA001','CAA002']},doc=doc) +contours=contours[np.all((contours[:,:2]>=lo)&(contours[:,:2]<=hi),axis=1)] +clean_dtm=DTM(contours,settings['max_edge']) +gx,gy=np.meshgrid(g['origin'][0]+np.arange(g['nx'])*g['step'],g['origin'][1]+np.arange(g['ny'])*g['step']) +clean=clean_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape) +clean_edges=np.concatenate([np.abs(np.diff(clean,axis=0)).ravel(),np.abs(np.diff(clean,axis=1)).ravel()]) +clean_edges=clean_edges[np.isfinite(clean_edges)] +summary['contour_only_diagnostic']={'layers':['CAA001','CAA002'],'counts':contour_counts,'valid_nodes':int(np.isfinite(clean).sum()),'z_range':[float(np.nanmin(clean)),float(np.nanmax(clean))],'max_adjacent_dz':float(clean_edges.max()),'edges_over_5m':int((clean_edges>5).sum()),'edges_over_8m':int((clean_edges>8).sum()),'not_applied':True} +typed_cfg=GroundConfig(use_profile_points=False,z_min=settings['z_min'],z_max=settings['z_max']) +typed_cfg.allowed_terrain_layers=typed_cfg.layers.spot_block+typed_cfg.layers.spot_text+typed_cfg.layers.contour +typed=read_terrain_dxf(source,typed_cfg,document=doc) +typed_local=typed.points[np.all((typed.points[:,:2]>=lo)&(typed.points[:,:2]<=hi),axis=1)] +typed_dtm=DTM(typed_local,settings['max_edge']) +typed_grid=typed_dtm.z_many(np.column_stack([gx.ravel(),gy.ravel()])).reshape(z.shape) +typed_edges=np.concatenate([np.abs(np.diff(typed_grid,axis=0)).ravel(),np.abs(np.diff(typed_grid,axis=1)).ravel()]) +typed_edges=typed_edges[np.isfinite(typed_edges)] +summary['typed_layer_diagnostic']={'layers':list(typed_cfg.allowed_terrain_layers),'counts':typed.stats['by_source'],'valid_nodes':int(np.isfinite(typed_grid).sum()),'z_range':[float(np.nanmin(typed_grid)),float(np.nanmax(typed_grid))],'max_adjacent_dz':float(typed_edges.max()),'edges_over_5m':int((typed_edges>5).sum()),'edges_over_8m':int((typed_edges>8).sum()),'covered_piers':sum(typed_dtm.z_at(p['X'],p['Y']) is not None for p in data['piers']),'not_applied':True} +summary['pier_diagnostic']=[] +for pier in data['piers']: + observed=clean_dtm.z_at(pier['X'],pier['Y']) + summary['pier_diagnostic'].append({'id':pier['id'],'generated':pier['ground']['center'],'contour_only':observed,'difference':None if observed is None else observed-pier['ground']['center']}) +for e in summary['worst_edges']: + triangles=[] + for row,col in [(e['row'],e['col']),(e['to_row'],e['to_col'])]: + xy=np.array(g['origin'])+np.array([col,row])*g['step']; k=int(dtm.tri.find_simplex(xy-dtm.origin)) + triangles.append({'xy':xy.tolist(),'vertices':points[dtm.tri.simplices[k]].tolist() if k>=0 else [],'predicted':dtm.z_at(*xy)}) + e['source_triangles']=triangles +# Raw constant-height CAD entities retained in the selected TIN: look for +# closed design outlines vs survey contours near the worst interpolation. +raw=[] +for entity in doc.blocks.get(settings['terrain_block']): + if entity.dxftype() not in ('LWPOLYLINE','POLYLINE'):continue + pts=np.array([tuple(p) for p in (entity.vertices_in_wcs() if entity.dxftype()=='LWPOLYLINE' else entity.points_in_wcs())]) + if len(pts)<2 or np.ptp(pts[:,2])>1e-6:continue + raw.append({'handle':entity.dxf.handle,'layer':entity.dxf.layer,'z':float(pts[0,2]),'vertices':len(pts),'closed':bool(entity.closed if entity.dxftype()=='LWPOLYLINE' else entity.is_closed),'xy_min':pts[:,:2].min(0).tolist(),'xy_max':pts[:,:2].max(0).tolist(),'xyz':pts.tolist()}) +for e in summary['worst_edges'][:6]: + for tri in e['source_triangles']: + tri['entities']=[] + for north,east,height in tri['vertices']: + candidates=[] + for entity in raw: + if abs(entity['z']-height)>1e-3:continue + xy=np.array(entity['xyz'])[:,:2]; d=float(np.linalg.norm(xy-[east,north],axis=1).min()) + candidates.append((d,entity)) + candidates.sort(key=lambda r:r[0]) + tri['entities'].append([{'distance_to_vertex':round(d,4),**{k:v for k,v in ent.items() if k!='xyz'}} for d,ent in candidates[:2]]) +(out/'report.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2)) +print(json.dumps({k:v for k,v in summary.items() if k not in ('worst_edges','provenance')},ensure_ascii=False)) +for e in summary['worst_edges'][:3]:print(json.dumps(e,ensure_ascii=False)) +try: + import matplotlib + matplotlib.use('Agg') + import matplotlib.pyplot as plt + fig,axes=plt.subplots(1,3,figsize=(16,5),constrained_layout=True) + extent=[g['origin'][0],g['origin'][0]+(g['nx']-1)*g['step'],g['origin'][1],g['origin'][1]+(g['ny']-1)*g['step']] + for ax,values,title in [(axes[0],z,'Generated: all height-bearing layers'),(axes[1],typed_grid,'Diagnostic: survey spots + contour layers')]: + im=ax.imshow(values,origin='lower',extent=extent,vmin=20,vmax=47,aspect='auto',cmap='terrain');ax.set_title(title);ax.set_xlabel('Northing (m)');ax.set_ylabel('Easting (m)') + fig.colorbar(im,ax=axes[:2],label='EL (m)',shrink=.7) + row=summary['worst_edges'][0]['to_row'];axis=g['origin'][0]+np.arange(g['nx'])*g['step'] + axes[2].plot(axis,z[row],label='Generated');axes[2].plot(axis,typed_grid[row],label='Survey spots + contours');axes[2].set_title(f'Transect E={g["origin"][1]+row*g["step"]:.3f} m');axes[2].set_xlabel('Northing (m)');axes[2].set_ylabel('EL (m)');axes[2].legend();axes[2].grid(alpha=.25) + fig.savefig(out/'comparison.png',dpi=150);plt.close(fig) +except ImportError: + print('Plot dependency unavailable; numeric report preserved.') diff --git a/scripts/verify-ungok-ground.cjs b/scripts/verify-ungok-ground.cjs new file mode 100644 index 0000000..572ed38 --- /dev/null +++ b/scripts/verify-ungok-ground.cjs @@ -0,0 +1,49 @@ +// Run with Windows Node against isolated Chrome CDP 9444 and Vite 5173. +const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'); +const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/ungok-terrain-fixed-20261110/browser'); +fs.mkdirSync(out,{recursive:true}); +const report={checks:[],errors:[]};let ws; +const delay=ms=>new Promise(r=>setTimeout(r,ms)); +(async()=>{ + const tab=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json(); + ws=new WebSocket(tab.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;}); + let seq=0;const pending=new Map(); + ws.onmessage=({data})=>{const m=JSON.parse(data),p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(Error(JSON.stringify(m.error))):p.resolve(m.result);}if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);}; + const send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>reject(Error('Timeout '+method)),30000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));}); + const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;}; + const until=async expression=>{for(let i=0;i<150;i++){if(await ev(expression))return;await delay(200);}throw Error('Wait '+expression);}; + const check=(name,value)=>{assert.ok(value,name);report.checks.push(name);console.log('PASS',name);}; + const click=expression=>ev(`(()=>{const e=${expression};if(!e||e.disabled)throw Error('Missing target '+${JSON.stringify(expression)});e.click();})()`); + const file=async(selector,name)=>{const d=await send('DOM.getDocument'),{nodeId}=await send('DOM.querySelector',{nodeId:d.root.nodeId,selector});await send('DOM.setFileInputFiles',{nodeId,files:[name]});}; + const shot=async name=>{const s=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(s.data,'base64'));}; + await send('Page.enable');await send('Runtime.enable');await send('DOM.enable'); + await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false}); + await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`); + const settings=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/generation_settings.json'))); + const workbook='\\\\wsl.localhost\\Ubuntu'+settings.piers.replaceAll('/','\\'); + await ev('window.openSampleProject()');await file('#file',workbook); + await until(`document.getElementById('file-name').textContent.includes('piers.xlsx')`); + await ev(`(()=>{const e=document.getElementById('project-name');e.value='운곡교 지반 수정 검증';e.dispatchEvent(new Event('input'));document.getElementById('import-dialog').showModal();})()`); + await until(`!document.getElementById('dlg-apply').disabled`); + await click(`document.getElementById('dlg-apply')`);await click(`document.getElementById('nav-shape')`);await click(`document.getElementById('project-ground-open')`); + check('actual workbook lists all 16 structures',await ev(`document.querySelectorAll('[data-ground-pier]').length===16`)); + await file('#ground-import',path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json')); + await until(`document.getElementById('project-ground-status').textContent.includes('16/16')`); + check('all structures have applicable corrected ground',await ev(`[...document.querySelectorAll('[data-ground-pier]')].every(e=>!e.disabled)`)); + await click(`document.getElementById('project-ground-select-all')`); + check('all 16 structures are selected for application',await ev(`document.querySelectorAll('[data-ground-pier]:checked').length===16`)); + await click(`document.getElementById('project-ground-apply')`); + await click(`document.getElementById('project-ground-regenerate')`); + await until(`document.getElementById('project-ground-regenerate').disabled`); + check('all changes regenerate successfully',await ev(`document.getElementById('project-ground-regenerate').disabled`)); + await shot('01-corrected-manager');await click(`document.querySelector('#project-ground-dialog .viewer-head button')`); + await delay(1000);await shot('02-corrected-terrain'); + await send('Browser.setDownloadBehavior',{behavior:'deny'}); + await ev(`(()=>{window.__ungokSaved=null;const original=URL.createObjectURL;URL.createObjectURL=function(blob){blob.text().then(t=>{try{window.__ungokSaved=JSON.parse(t);}catch{}});return original.call(this,blob);};})()`); + await click(`document.getElementById('save-project')`);await until(`document.getElementById('save-dialog').open`);await click(`document.getElementById('sv-save')`);await until(`!!window.__ungokSaved?.groundModel`); + const saved=await ev(`window.__ungokSaved`);fs.writeFileSync(path.join(out,'verified.pzr.json'),JSON.stringify(saved)); + check('saved project retains 16 corrected ground records and two maps',saved.groundModel.piers.length===16&&saved.groundModel.mapLayers.length===2); + const expected=JSON.parse(fs.readFileSync(path.join(root,'pierzainer_ground/data/output/ungok_corrected_20261110/ground_model_3d_with_maps.json'))); + check('saved terrain elevations equal generated JSON',JSON.stringify(saved.groundModel.terrainGrid.z)===JSON.stringify(expected.terrain_grid.z)); + check('browser runtime has no exceptions',report.errors.length===0);report.result='PASS'; +})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));ws?.close();});