This commit is contained in:
307
scripts/verify-guide-floor-basemaps.mjs
Normal file
307
scripts/verify-guide-floor-basemaps.mjs
Normal file
@@ -0,0 +1,307 @@
|
||||
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 <path>] [--vite-url <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
|
||||
})
|
||||
Reference in New Issue
Block a user