308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
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
|
|
})
|