Files
frontend-miniapp/scripts/verify-guide-floor-basemaps.mjs
lyf 9ea1bfab71
Some checks failed
CI / verify (push) Has been cancelled
同步 sgs-frontend-mobile 源码
2026-07-27 11:26:01 +08:00

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
})