import { readFile } from 'node:fs/promises' import path from 'node:path' import process from 'node:process' import { chromium } from '@playwright/test' const DEFAULT_MANIFEST = 'static/guide-floor-basemaps/manifest.json' const DEFAULT_VITE_URL = 'http://127.0.0.1:5173' const PROJECTION_VERSION = 'ortho-yaw54-v2' const COMPOSITION_VERSION = 'compact-floor-center-v1' const EXPECTED_FLOOR_COUNT = 9 const EXPECTED_COMPOSITE_COUNT = 1 const GUIDE_CAMERA_YAW_DEGREES = 54 const GUIDE_CAMERA_FOV_DEGREES = 42 const OVERVIEW_CAMERA_DISTANCE = 720 const INDOOR_REFERENCE_FIT_DIMENSION = 270.39 const INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR = 0.56 const OVERVIEW_MAX_DISTANCE_FACTOR = 1.35 const FLOOR_MAX_DISTANCE_FACTOR = 1.18 const OVERVIEW_SCREEN_OFFSET_RATIO_X = -0.045 const FLOOR_SCREEN_OFFSET_RATIO_X = -0.05 const GUIDE_CAMERA_TARGET = { x: 29.0598, z: 23.9424 } const MIN_INITIAL_VIEWPORT_CSS_WIDTH = 390 const MIN_INITIAL_VIEWPORT_PIXEL_DENSITY = 1 const MAX_WEBP_BYTES = 1_500_000 const MAX_TOTAL_WEBP_BYTES = 3_000_000 const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value) const verifyOrthographicProjection = (floor) => { const projection = floor.projection if (projection?.type !== 'orthographic-xz') { throw new Error(`${floor.floorCode}: projection must be orthographic-xz`) } if ( !isFiniteNumber(projection.minX) || !isFiniteNumber(projection.maxX) || !isFiniteNumber(projection.minZ) || !isFiniteNumber(projection.maxZ) || !isFiniteNumber(projection.planeY) || !isFiniteNumber(projection.center?.x) || !isFiniteNumber(projection.center?.z) || !isFiniteNumber(projection.width) || !isFiniteNumber(projection.height) || !isFiniteNumber(projection.screenRight?.x) || !isFiniteNumber(projection.screenRight?.z) || !isFiniteNumber(projection.screenDown?.x) || !isFiniteNumber(projection.screenDown?.z) || projection.maxX <= projection.minX || projection.maxZ <= projection.minZ || projection.width <= 0 || projection.height <= 0 ) { throw new Error(`${floor.floorCode}: projection contract is invalid`) } const rightLength = Math.hypot(projection.screenRight.x, projection.screenRight.z) const downLength = Math.hypot(projection.screenDown.x, projection.screenDown.z) const basisDot = projection.screenRight.x * projection.screenDown.x + projection.screenRight.z * projection.screenDown.z if ( Math.abs(rightLength - 1) > 1e-6 || Math.abs(downLength - 1) > 1e-6 || Math.abs(basisDot) > 1e-6 ) { throw new Error(`${floor.floorCode}: projection basis must be normalized and orthogonal`) } const yaw = GUIDE_CAMERA_YAW_DEGREES * Math.PI / 180 if ( Math.abs(projection.screenRight.x - Math.cos(yaw)) > 1e-6 || Math.abs(projection.screenRight.z + Math.sin(yaw)) > 1e-6 || Math.abs(projection.screenDown.x - Math.sin(yaw)) > 1e-6 || Math.abs(projection.screenDown.z - Math.cos(yaw)) > 1e-6 ) { throw new Error(`${floor.floorCode}: projection basis does not match yaw ${GUIDE_CAMERA_YAW_DEGREES}`) } const projectionAspect = projection.width / projection.height const imageAspect = floor.image.width / floor.image.height if (Math.abs(projectionAspect - imageAspect) > 1e-6) { throw new Error(`${floor.floorCode}: projection/image aspect mismatch`) } const initial = floor.initialViewport if ( !isFiniteNumber(initial?.centerX) || !isFiniteNumber(initial?.centerZ) || !isFiniteNumber(initial?.visibleWorldSpan) || !isFiniteNumber(initial?.maxVisibleWorldSpan) || initial.visibleWorldSpan <= 0 || initial.maxVisibleWorldSpan < initial.visibleWorldSpan ) { throw new Error(`${floor.floorCode}: initialViewport is invalid`) } const isComposite = floor.role === 'SAME_GROUND_COMPOSITE' const isExterior = String(floor.floorCode).toUpperCase() === 'EXTERIOR' const isOverviewLike = isExterior || isComposite const distance = isOverviewLike ? OVERVIEW_CAMERA_DISTANCE : Math.abs( INDOOR_REFERENCE_FIT_DIMENSION / Math.sin((GUIDE_CAMERA_FOV_DEGREES * Math.PI / 180) / 2) ) * INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR const expectedInitialSpan = 2 * distance * Math.tan((GUIDE_CAMERA_FOV_DEGREES * Math.PI / 180) / 2) * imageAspect const expectedMaxSpan = expectedInitialSpan * ( isOverviewLike ? OVERVIEW_MAX_DISTANCE_FACTOR : FLOOR_MAX_DISTANCE_FACTOR ) if ( Math.abs(initial.visibleWorldSpan - expectedInitialSpan) > 1e-6 || Math.abs(initial.maxVisibleWorldSpan - expectedMaxSpan) > 1e-6 ) { throw new Error(`${floor.floorCode}: initial viewport does not match the live GLB camera contract`) } const floorLevelMatch = /^L(-?\d+(?:\.\d+)?)$/.exec(String(floor.floorCode).toUpperCase()) const floorLevel = floorLevelMatch ? Number(floorLevelMatch[1]) : Number.NaN const useCompactFloorVisualCenter = isComposite || floorLevel === 1.5 || floorLevel >= 3 const modelCenter = { x: (floor.bounds.min.x + floor.bounds.max.x) / 2, z: (floor.bounds.min.z + floor.bounds.max.z) / 2 } const baseCenter = useCompactFloorVisualCenter ? modelCenter : GUIDE_CAMERA_TARGET const overviewVisibleWorldSpan = 2 * OVERVIEW_CAMERA_DISTANCE * Math.tan((GUIDE_CAMERA_FOV_DEGREES * Math.PI / 180) / 2) * imageAspect const horizontalScreenOffset = useCompactFloorVisualCenter ? FLOOR_SCREEN_OFFSET_RATIO_X * expectedInitialSpan : OVERVIEW_SCREEN_OFFSET_RATIO_X * overviewVisibleWorldSpan const expectedCenter = { x: baseCenter.x + projection.screenRight.x * horizontalScreenOffset, z: baseCenter.z + projection.screenRight.z * horizontalScreenOffset } if ( Math.abs(initial.centerX - expectedCenter.x) > 1e-6 || Math.abs(initial.centerZ - expectedCenter.z) > 1e-6 || Math.abs(projection.center.x - expectedCenter.x) > 1e-6 || Math.abs(projection.center.z - expectedCenter.z) > 1e-6 ) { throw new Error(`${floor.floorCode}: projection center does not match the live GLB floor visual-center contract`) } const expectedStrategy = useCompactFloorVisualCenter ? 'model-box-center-with-screen-offset' : 'shared-overview-target-with-screen-offset' if ( floor.visualCenter?.strategy !== expectedStrategy || Math.abs(floor.visualCenter?.base?.x - baseCenter.x) > 1e-6 || Math.abs(floor.visualCenter?.base?.z - baseCenter.z) > 1e-6 || Math.abs(floor.visualCenter?.horizontalScreenOffset - horizontalScreenOffset) > 1e-6 ) { throw new Error(`${floor.floorCode}: visual-center metadata is invalid`) } const corners = [ { x: floor.bounds.min.x, z: floor.bounds.min.z }, { x: floor.bounds.min.x, z: floor.bounds.max.z }, { x: floor.bounds.max.x, z: floor.bounds.min.z }, { x: floor.bounds.max.x, z: floor.bounds.max.z } ] for (const point of corners) { const deltaX = point.x - projection.center.x const deltaZ = point.z - projection.center.z const right = deltaX * projection.screenRight.x + deltaZ * projection.screenRight.z const down = deltaX * projection.screenDown.x + deltaZ * projection.screenDown.z if (Math.abs(right) > projection.width / 2 + 1e-6 || Math.abs(down) > projection.height / 2 + 1e-6) { throw new Error(`${floor.floorCode}: projection coverage does not contain the model bounds`) } } const initialSourcePixels = floor.image.width * initial.visibleWorldSpan / projection.width const density = initialSourcePixels / MIN_INITIAL_VIEWPORT_CSS_WIDTH if (density + 1e-6 < MIN_INITIAL_VIEWPORT_PIXEL_DENSITY) { throw new Error(`${floor.floorCode}: initial viewport pixel density is too low (${density})`) } } const parseArgs = (argv) => { const values = new Map() for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (argument === '--help' || argument === '-h') return { help: true, values } if (!argument.startsWith('--')) throw new Error(`Unknown argument: ${argument}`) const [key, inlineValue] = argument.slice(2).split('=', 2) const value = inlineValue ?? argv[index + 1] if (!value || value.startsWith('--')) throw new Error(`Missing value for --${key}`) if (inlineValue === undefined) index += 1 if (!['manifest', 'vite-url'].includes(key)) throw new Error(`Unknown argument: --${key}`) values.set(key, value) } return { help: false, values } } const main = async () => { const parsed = parseArgs(process.argv.slice(2)) if (parsed.help) { console.log('Usage: npm run verify:guide-floor-basemaps -- [--manifest ] [--vite-url ]') return } const manifestPath = path.resolve(parsed.values.get('manifest') || process.env.GUIDE_BASEMAP_MANIFEST || DEFAULT_MANIFEST) const viteUrl = (parsed.values.get('vite-url') || process.env.GUIDE_BASEMAP_VITE_URL || DEFAULT_VITE_URL).replace(/\/+$/, '') const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) if (!Array.isArray(manifest.floors) || manifest.floors.length !== EXPECTED_FLOOR_COUNT) { throw new Error(`Basemap manifest must contain ${EXPECTED_FLOOR_COUNT} floor entries`) } if (!Array.isArray(manifest.routeAssets) || manifest.routeAssets.length !== EXPECTED_COMPOSITE_COUNT) { throw new Error(`Basemap manifest must contain ${EXPECTED_COMPOSITE_COUNT} composite route entry`) } if ( manifest.schemaVersion !== 4 || manifest.render?.projection !== 'orthographic-xz' || manifest.render?.projectionVersion !== PROJECTION_VERSION || manifest.render?.compositionVersion !== COMPOSITION_VERSION ) { throw new Error('Basemap manifest is not an orthographic schema v4 manifest') } const composite = manifest.routeAssets[0] const expectedCoverageCodes = ['EXTERIOR', 'L1'] const expectedCoverageIds = expectedCoverageCodes.map((floorCode) => { const floor = manifest.floors.find((entry) => entry.floorCode === floorCode) if (!floor) throw new Error(`Composite coverage floor ${floorCode} is missing from ordinary floor entries`) return String(floor.floorId) }) if ( composite.role !== 'SAME_GROUND_COMPOSITE' || composite.floorCode !== 'EXTERIOR_L1' || !composite.assetId || !composite.modelUrl || !composite.modelVersion || JSON.stringify(composite.coverageFloorCodes) !== JSON.stringify(expectedCoverageCodes) || JSON.stringify(composite.coverageFloorIds) !== JSON.stringify(expectedCoverageIds) ) { throw new Error('EXTERIOR_L1 composite metadata or coverage does not match the SDK floor identities') } const entries = [...manifest.floors, ...manifest.routeAssets] for (const floor of entries) { verifyOrthographicProjection(floor) if (!/\.ortho-yaw54-v2\.compact-floor-center-v1\.webp$/i.test(String(floor.image?.path || ''))) { throw new Error(`${floor.floorCode}: image URL is missing the ${PROJECTION_VERSION}/${COMPOSITION_VERSION} cache identity`) } } const browser = await chromium.launch({ headless: true }) const page = await browser.newPage() await page.goto(`${viteUrl}/`, { waitUntil: 'domcontentloaded' }) let totalWebpBytes = 0 try { for (const floor of entries) { const imagePath = String(floor.image?.path || '') const response = await page.request.get(`${viteUrl}${imagePath}`) if (!response.ok()) throw new Error(`${floor.floorCode}: image is unavailable at ${imagePath} (${response.status()})`) const bytes = await response.body() if (bytes.length > MAX_WEBP_BYTES) { throw new Error(`${floor.floorCode}: WebP exceeds the weak-network size budget (${bytes.length} bytes)`) } if (Number(floor.image?.bytes) !== bytes.length) { throw new Error(`${floor.floorCode}: manifest byte count does not match the WebP file`) } totalWebpBytes += bytes.length const inspection = await page.evaluate(async ({ base64, expectedWidth, expectedHeight }) => { const image = new Image() image.src = `data:image/webp;base64,${base64}` await image.decode() const canvas = document.createElement('canvas') canvas.width = image.naturalWidth canvas.height = image.naturalHeight const context = canvas.getContext('2d', { willReadFrequently: true }) context.drawImage(image, 0, 0) const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data const first = [pixels[0], pixels[1], pixels[2]] let differingPixels = 0 for (let index = 0; index < pixels.length; index += 4) { const difference = Math.abs(pixels[index] - first[0]) + Math.abs(pixels[index + 1] - first[1]) + Math.abs(pixels[index + 2] - first[2]) if (difference > 24 && pixels[index + 3] > 0) differingPixels += 1 } return { width: image.naturalWidth, height: image.naturalHeight, differingRatio: differingPixels / (canvas.width * canvas.height), dimensionsMatch: image.naturalWidth === expectedWidth && image.naturalHeight === expectedHeight } }, { base64: bytes.toString('base64'), expectedWidth: floor.image.width, expectedHeight: floor.image.height }) if (!inspection.dimensionsMatch || inspection.differingRatio < 0.003) { throw new Error(`${floor.floorCode}: invalid WebP (dimensionsMatch=${inspection.dimensionsMatch}, differingRatio=${inspection.differingRatio})`) } console.log(`Verified ${floor.floorCode}: ${inspection.width}x${inspection.height}, differing ratio ${inspection.differingRatio.toFixed(4)}`) } if (totalWebpBytes > MAX_TOTAL_WEBP_BYTES) { throw new Error(`WebP set exceeds the total weak-network budget (${totalWebpBytes} bytes)`) } console.log(`Verified ${entries.length} WebP files, total ${totalWebpBytes} bytes`) } finally { await browser.close() } } main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error) process.exitCode = 1 })