同步 sgs-frontend-mobile 源码
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-27 11:26:01 +08:00
parent 575dca430f
commit 9ea1bfab71
181 changed files with 24395 additions and 5212 deletions

View File

@@ -54,6 +54,19 @@ const copyPublicStaticSubtree = (subtree) => {
console.log(`Copied public static ${subtree} assets to ${path.relative(projectRoot, targetDir)}`)
}
const copyPublicRootSubtree = (subtree) => {
const sourceDir = path.join(projectRoot, 'public', subtree)
const targetDir = path.join(h5Root, subtree)
if (!fs.existsSync(sourceDir)) {
throw new Error(`Public assets not found: ${sourceDir}`)
}
fs.rmSync(targetDir, { recursive: true, force: true })
copyDirectory(sourceDir, targetDir)
console.log(`Copied public ${subtree} assets to ${path.relative(projectRoot, targetDir)}`)
}
const copyStaticFile = (fileName) => {
const sourceFile = path.join(projectRoot, 'static', fileName)
const targetFile = path.join(h5Root, 'static', fileName)
@@ -96,6 +109,42 @@ const rewriteCopiedGuideDataUrls = () => {
}
}
const rewriteCopiedEngineUrls = () => {
const engineDir = path.join(h5Root, 'engine')
const replacements = [
['http://1.92.206.90:9000/museum-assets', '/museum-assets'],
['http://1.92.206.90:9000/tts-audio', '/tts-audio'],
['http://1.92.206.90:9000', 'http://legacy-minio.local:9000'],
['https://1.92.206.90:9000', 'https://legacy-minio.local:9000'],
['https://guide.whaoyue.com/museum-assets', '/museum-assets'],
['https://guide.whaoyue.com/tts-audio', '/tts-audio'],
['https://guide.whaoyue.com/app-api', '/app-api']
]
const rewriteTextFiles = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, entry.name)
if (entry.isDirectory()) {
rewriteTextFiles(filePath)
continue
}
if (!entry.isFile() || !/\.(?:html|js|mjs|json|css|map)$/i.test(entry.name)) continue
let text = fs.readFileSync(filePath, 'utf8')
const original = text
for (const [from, to] of replacements) {
text = text.split(from).join(to)
}
if (text !== original) {
fs.writeFileSync(filePath, text, 'utf8')
console.log(`Rewrote engine URLs in ${path.relative(projectRoot, filePath)}`)
}
}
}
rewriteTextFiles(engineDir)
}
copyStaticSubtree('nav-assets')
copyStaticSubtree('guide-data')
rewriteCopiedGuideDataUrls()
@@ -108,6 +157,8 @@ copyStaticSubtree('three')
copyStaticFile('exhibit-placeholder.jpg')
copyStaticFile('hall-placeholder.jpg')
copyPublicStaticSubtree('Fonts')
copyPublicRootSubtree('engine')
rewriteCopiedEngineUrls()
if (fs.existsSync(faviconSource)) {
fs.copyFileSync(faviconSource, faviconTarget)

View File

@@ -1,36 +1,29 @@
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const {
isTextBuildFile,
resolveH5BuildPolicy,
tencentMapKeyPlaceholder
} = require('./finalize-h5-build-policy.cjs')
require('./copy-h5-nav-assets.cjs')
const projectRoot = path.resolve(__dirname, '..')
const h5Root = path.join(projectRoot, 'dist', 'build', 'h5')
const placeholder = tencentMapKeyPlaceholder
const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__'
const badPatterns = [
'http://1.92.206.90:9000',
'1.92.206.90',
'guide.whaoyue.com',
placeholder
]
const {
allowPlaceholder,
requireTencentMapKey,
tencentMapKey
} = resolveH5BuildPolicy({
args: process.argv.slice(2),
projectRoot
})
require('./copy-h5-nav-assets.cjs')
const args = new Set(process.argv.slice(2))
const requireTencentMapKey = args.has('--require-tencent-map-key')
const allowPlaceholder = args.has('--allow-placeholder')
const tencentMapKey = (process.env.VITE_TENCENT_MAP_KEY || '').trim()
if (!fs.existsSync(h5Root)) {
throw new Error(`H5 build output not found: ${h5Root}`)
}
const isTextFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml)$/i.test(filePath)
const walkFiles = (dir, files = []) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, entry.name)
@@ -49,21 +42,16 @@ const walkFiles = (dir, files = []) => {
}
const allFiles = walkFiles(h5Root)
const textFiles = allFiles.filter(isTextBuildFile)
const textFiles = allFiles.filter(isTextFile)
const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder))
const hasUsableTencentMapKey = tencentMapKey.length > 0
const missingTencentMapKeyMessage = [
'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.',
'Inject it through CI or an ignored .env.production.local file.'
].join(' ')
if (requireTencentMapKey && !hasUsableTencentMapKey) {
throw new Error(missingTencentMapKeyMessage)
}
const hasUsableTencentMapKey = tencentMapKey.length > 0 && tencentMapKey !== placeholder
if (placeholderFiles.length > 0) {
if (!hasUsableTencentMapKey) {
const message = `H5 build output still contains Tencent map key placeholder. ${missingTencentMapKeyMessage}`
const message = [
'H5 build output still contains Tencent map key placeholder.',
'Please set VITE_TENCENT_MAP_KEY before production build.'
].join(' ')
if (requireTencentMapKey && !allowPlaceholder) {
throw new Error(message)
@@ -120,25 +108,32 @@ for (const file of textFiles) {
}
if (badMatches.length > 0) {
if (allowPlaceholder) {
console.warn('[finalize-h5-build] Test H5 build allows non-production source hosts and the Tencent map key placeholder.')
if (allowPlaceholder && badMatches.every((item) => item.pattern === placeholder)) {
console.warn('[finalize-h5-build] Tencent map key placeholder is allowed for this build.')
} else {
console.error(JSON.stringify(badMatches.slice(0, 20), null, 2))
throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.')
}
}
const readJson = (relativePath) => JSON.parse(fs.readFileSync(path.join(h5Root, relativePath), 'utf8'))
const readJson = (relativePath, fallback = null) => {
const filePath = path.join(h5Root, relativePath)
if (!fs.existsSync(filePath)) return fallback
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const manifest = readJson('static/guide-data/manifest.json')
const guideContents = readJson('static/guide-data/guide-contents.json')
const exhibits = readJson('static/guide-data/exhibits.json')
const guideStops = readJson('static/guide-data/guide-stops.json')
const guideContents = readJson('static/guide-data/guide-contents.json', { rows: [] })
const exhibits = readJson('static/guide-data/exhibits.json', { rows: [] })
const guideStops = readJson('static/guide-data/guide-stops.json', { rows: [] })
const requiredFiles = [
'engine/index.html',
'engine/assets/index.js',
'engine/draco/draco_decoder.wasm',
'static/guide/museum-brand-icons.webp',
'static/icons/poi/shortcut-icons.svg',
'static/icons/marker-entrance.svg',
'static/Fonts/HarmonyOS_SansSC_Regular.ttf'
'static/Fonts/Comfortaa-VariableFont_wght.ttf'
]
const missingFiles = requiredFiles.filter((file) => !fs.existsSync(path.join(h5Root, file)))

View File

@@ -0,0 +1,619 @@
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { chromium } from '@playwright/test'
const DEFAULT_API_URL = 'http://localhost:48080'
const DEFAULT_MAP_ID = '1'
const DEFAULT_OUTPUT_DIR = 'static/guide-floor-basemaps'
const DEFAULT_VITE_URL = 'http://127.0.0.1:5173'
// Match the H5 guide canvas's portrait aspect ratio so a 2D/3D switch keeps
// the same visible composition instead of letterboxing a square top view.
const DEFAULT_WIDTH = 750
const DEFAULT_HEIGHT = 1500
const DEFAULT_PADDING = 0.08
const PROJECTION_VERSION = 'ortho-yaw54-v2'
const COMPOSITION_VERSION = 'compact-floor-center-v1'
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 usage = `Generate WebP floor basemaps from SDK GLB assets.
Usage:
npm run generate:guide-floor-basemaps -- [options]
Options:
--api <url> SDK API origin (default: ${DEFAULT_API_URL})
--map-id <id> SDK map id (default: ${DEFAULT_MAP_ID})
--out-dir <path> Output directory (default: ${DEFAULT_OUTPUT_DIR})
--vite-url <url> Existing Vite server URL (default: ${DEFAULT_VITE_URL})
--floor <code> Render only floor code(s), comma separated or repeated
--width <pixels> Output width (default: ${DEFAULT_WIDTH})
--height <pixels> Output height (default: ${DEFAULT_HEIGHT})
--padding <ratio> Reserved framing padding metadata (default: ${DEFAULT_PADDING})
--help Show this help
Environment overrides: GUIDE_BASEMAP_API_URL, GUIDE_BASEMAP_MAP_ID,
GUIDE_BASEMAP_OUTPUT_DIR, GUIDE_BASEMAP_VITE_URL, GUIDE_BASEMAP_FLOOR,
GUIDE_BASEMAP_WIDTH, GUIDE_BASEMAP_HEIGHT, GUIDE_BASEMAP_PADDING.`
const parseArgs = (argv) => {
const values = new Map()
const floorCodes = []
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (argument === '--help' || argument === '-h') return { help: true, values, floorCodes }
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 (key === 'floor') {
floorCodes.push(...value.split(',').map((item) => item.trim()).filter(Boolean))
} else if (['api', 'map-id', 'out-dir', 'vite-url', 'width', 'height', 'padding'].includes(key)) {
values.set(key, value)
} else {
throw new Error(`Unknown argument: --${key}`)
}
}
return { help: false, values, floorCodes }
}
const normalizeOrigin = (value) => value.replace(/\/+$/, '')
const resolveApiRoot = (value) => {
const normalized = normalizeOrigin(value)
return normalized.endsWith('/app-api') ? normalized : `${normalized}/app-api`
}
const asPositiveInteger = (value, option) => {
const number = Number(value)
if (!Number.isInteger(number) || number <= 0) throw new Error(`${option} must be a positive integer`)
return number
}
const asPadding = (value) => {
const number = Number(value)
if (!Number.isFinite(number) || number < 0 || number > 0.5) {
throw new Error('--padding must be between 0 and 0.5')
}
return number
}
const safeFilePart = (value) => String(value || 'unknown')
.replace(/[^A-Za-z0-9._-]+/g, '_')
.replace(/^_+|_+$/g, '') || 'unknown'
const toPublicPath = (absoluteOutputDir, fileName) => {
const staticRoot = path.resolve(process.cwd(), 'static')
const relative = path.relative(staticRoot, path.join(absoluteOutputDir, fileName))
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Output directory must be inside ${staticRoot}`)
}
return `/static/${relative.split(path.sep).join('/')}`
}
const getJson = async (url) => {
const response = await fetch(url)
if (!response.ok) throw new Error(`Request failed (${response.status}): ${url}`)
const payload = await response.json()
if (!payload || payload.code !== 0) {
throw new Error(`SDK request failed: ${url} code=${payload?.code ?? 'unknown'} msg=${payload?.msg || ''}`)
}
return payload.data
}
const getBinary = async (url) => {
const response = await fetch(url)
if (!response.ok) throw new Error(`GLB download failed (${response.status}): ${url}`)
const contentType = response.headers.get('content-type') || ''
const buffer = Buffer.from(await response.arrayBuffer())
if (buffer.length < 20) throw new Error(`GLB download is unexpectedly small: ${url}`)
return { buffer, contentType }
}
const resolveModelUrl = (apiOrigin, modelUrl) => {
if (/^https?:\/\//i.test(modelUrl)) return modelUrl
return new URL(modelUrl, `${apiOrigin}/`).toString()
}
const createRenderer = async (page, options) => page.evaluate(async ({
assetUrl,
width,
height,
padding,
floorCode,
resourceKind,
decoderPath,
cameraContract
}) => {
const THREE = await import('/node_modules/three/build/three.module.js')
const { GLTFLoader } = await import('/node_modules/three/examples/jsm/loaders/GLTFLoader.js')
const { DRACOLoader } = await import('/node_modules/three/examples/jsm/loaders/DRACOLoader.js')
const decodeGlb = async () => {
const response = await fetch(assetUrl)
if (!response.ok) throw new Error(`Virtual GLB request failed: ${response.status}`)
return response.arrayBuffer()
}
const source = await decodeGlb()
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath(decoderPath)
dracoLoader.setDecoderConfig({ type: 'wasm' })
const loader = new GLTFLoader()
loader.setDRACOLoader(dracoLoader)
const gltf = await new Promise((resolve, reject) => {
loader.parse(source, '', resolve, reject)
})
const scene = new THREE.Scene()
scene.background = new THREE.Color('#eceff1')
const model = gltf.scene
model.updateMatrixWorld(true)
const bounds = new THREE.Box3().setFromObject(model)
if (bounds.isEmpty()) throw new Error('GLB contains no renderable bounds')
model.traverse((object) => {
if (!object.isMesh) return
const sourceMaterial = Array.isArray(object.material) ? object.material[0] : object.material
object.material = sourceMaterial?.clone?.() || new THREE.MeshStandardMaterial({
color: new THREE.Color('#bfd0c6'),
side: THREE.DoubleSide
})
object.castShadow = false
object.receiveShadow = false
})
scene.add(model)
const modelCenter = bounds.getCenter(new THREE.Vector3())
const size = bounds.getSize(new THREE.Vector3())
const span = Math.max(size.x, size.y, size.z, 1)
const aspect = width / height
const yaw = THREE.MathUtils.degToRad(cameraContract.yawDegrees)
const screenRight = { x: Math.cos(yaw), z: -Math.sin(yaw) }
const screenDown = { x: Math.sin(yaw), z: Math.cos(yaw) }
const normalizedFloorCode = String(floorCode).trim().toUpperCase()
const isExterior = normalizedFloorCode === 'EXTERIOR'
const isComposite = resourceKind === 'route-asset'
const isOverviewLike = isExterior || isComposite
const floorLevelMatch = /^L(-?\d+(?:\.\d+)?)$/.exec(normalizedFloorCode)
const floorLevel = floorLevelMatch ? Number(floorLevelMatch[1]) : Number.NaN
const useCompactFloorVisualCenter = isComposite || floorLevel === 1.5 || floorLevel >= 3
const defaultDistance = isOverviewLike
? cameraContract.overviewDistance
: Math.abs(
cameraContract.indoorReferenceFitDimension
/ Math.sin(THREE.MathUtils.degToRad(cameraContract.fovDegrees) / 2)
) * cameraContract.indoorDistanceFactor
const defaultVisibleWorldSpan = 2
* defaultDistance
* Math.tan(THREE.MathUtils.degToRad(cameraContract.fovDegrees) / 2)
* aspect
const maxVisibleWorldSpan = defaultVisibleWorldSpan * (
isOverviewLike ? cameraContract.overviewMaxDistanceFactor : cameraContract.floorMaxDistanceFactor
)
const overviewVisibleWorldSpan = 2
* cameraContract.overviewDistance
* Math.tan(THREE.MathUtils.degToRad(cameraContract.fovDegrees) / 2)
* aspect
const baseVisualCenter = useCompactFloorVisualCenter
? { x: modelCenter.x, z: modelCenter.z }
: cameraContract.target
const horizontalScreenOffset = useCompactFloorVisualCenter
? cameraContract.floorScreenOffsetRatioX * defaultVisibleWorldSpan
: cameraContract.overviewScreenOffsetRatioX * overviewVisibleWorldSpan
const projectionCenter = {
x: baseVisualCenter.x + screenRight.x * horizontalScreenOffset,
z: baseVisualCenter.z + screenRight.z * horizontalScreenOffset
}
const modelCorners = [
{ x: bounds.min.x, z: bounds.min.z },
{ x: bounds.min.x, z: bounds.max.z },
{ x: bounds.max.x, z: bounds.min.z },
{ x: bounds.max.x, z: bounds.max.z }
]
const modelBasisExtents = modelCorners.reduce((extents, point) => {
const deltaX = point.x - projectionCenter.x
const deltaZ = point.z - projectionCenter.z
return {
right: Math.max(extents.right, Math.abs(deltaX * screenRight.x + deltaZ * screenRight.z)),
down: Math.max(extents.down, Math.abs(deltaX * screenDown.x + deltaZ * screenDown.z))
}
}, { right: 0, down: 0 })
const requiredWidth = Math.max(2 * modelBasisExtents.right * (1 + padding), 1)
const requiredHeight = Math.max(2 * modelBasisExtents.down * (1 + padding), 1)
const projectionWidth = Math.max(
maxVisibleWorldSpan,
requiredWidth,
requiredHeight * aspect
)
const projectionHeight = projectionWidth / aspect
const coverageCorners = [
[-projectionWidth / 2, -projectionHeight / 2],
[-projectionWidth / 2, projectionHeight / 2],
[projectionWidth / 2, -projectionHeight / 2],
[projectionWidth / 2, projectionHeight / 2]
].map(([right, down]) => ({
x: projectionCenter.x + right * screenRight.x + down * screenDown.x,
z: projectionCenter.z + right * screenRight.z + down * screenDown.z
}))
const minX = Math.min(...coverageCorners.map((point) => point.x))
const maxX = Math.max(...coverageCorners.map((point) => point.x))
const minZ = Math.min(...coverageCorners.map((point) => point.z))
const maxZ = Math.max(...coverageCorners.map((point) => point.z))
const planeY = modelCenter.y
const requiredDensityWidth = cameraContract.minimumInitialViewportCssWidth
* cameraContract.minimumInitialViewportPixelDensity
* projectionWidth / defaultVisibleWorldSpan
const resolutionScale = Math.max(1, Math.ceil(requiredDensityWidth / width))
const outputWidth = width * resolutionScale
const outputHeight = height * resolutionScale
const initialViewportSourcePixels = outputWidth * defaultVisibleWorldSpan / projectionWidth
const initialViewportPixelDensity = initialViewportSourcePixels
/ cameraContract.minimumInitialViewportCssWidth
const cameraHeight = bounds.max.y + Math.max(projectionWidth, projectionHeight, 100)
const camera = new THREE.OrthographicCamera(
-projectionWidth / 2,
projectionWidth / 2,
projectionHeight / 2,
-projectionHeight / 2,
Math.max(0.1, span / 1000),
Math.max(20000, span * 20)
)
camera.position.set(projectionCenter.x, cameraHeight, projectionCenter.z)
camera.up.set(-screenDown.x, 0, -screenDown.z)
camera.lookAt(projectionCenter.x, planeY, projectionCenter.z)
camera.updateProjectionMatrix()
camera.updateMatrixWorld()
scene.add(new THREE.HemisphereLight('#ffffff', '#b0bec5', 1.7))
const keyLight = new THREE.DirectionalLight('#ffffff', 2.1)
keyLight.position.set(80, 120, 80)
scene.add(keyLight)
const fillLight = new THREE.DirectionalLight('#ffffff', 0.55)
fillLight.position.set(-60, 70, -50)
scene.add(fillLight)
const canvas = document.createElement('canvas')
canvas.width = outputWidth
canvas.height = outputHeight
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true })
renderer.setPixelRatio(1)
renderer.setSize(outputWidth, outputHeight, false)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.NoToneMapping
renderer.toneMappingExposure = 1
renderer.render(scene, camera)
const dataUrl = canvas.toDataURL('image/webp', 0.9)
const image = new Image()
image.src = dataUrl
await image.decode()
const inspectionCanvas = document.createElement('canvas')
inspectionCanvas.width = outputWidth
inspectionCanvas.height = outputHeight
const inspectionContext = inspectionCanvas.getContext('2d', { willReadFrequently: true })
if (!inspectionContext) throw new Error('Unable to inspect rendered image pixels')
inspectionContext.drawImage(image, 0, 0)
const pixels = inspectionContext.getImageData(0, 0, outputWidth, outputHeight).data
const background = [236, 239, 241]
let nonBackgroundPixels = 0
for (let index = 0; index < pixels.length; index += 4) {
const difference = Math.abs(pixels[index] - background[0])
+ Math.abs(pixels[index + 1] - background[1])
+ Math.abs(pixels[index + 2] - background[2])
if (difference > 24 && pixels[index + 3] > 0) nonBackgroundPixels += 1
}
renderer.dispose()
dracoLoader.dispose()
scene.clear()
return {
dataUrl,
pixelStats: {
nonBackgroundPixels,
nonBackgroundRatio: nonBackgroundPixels / (outputWidth * outputHeight)
},
bounds: {
min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
size: { x: size.x, y: size.y, z: size.z }
},
projection: {
type: 'orthographic-xz',
minX,
maxX,
minZ,
maxZ,
planeY,
center: projectionCenter,
width: projectionWidth,
height: projectionHeight,
screenRight,
screenDown
},
initialViewport: {
centerX: projectionCenter.x,
centerZ: projectionCenter.z,
visibleWorldSpan: defaultVisibleWorldSpan,
maxVisibleWorldSpan
},
visualCenter: {
strategy: useCompactFloorVisualCenter
? 'model-box-center-with-screen-offset'
: 'shared-overview-target-with-screen-offset',
base: baseVisualCenter,
horizontalScreenOffset,
floorLevel: Number.isFinite(floorLevel) ? floorLevel : null
},
output: {
width: outputWidth,
height: outputHeight,
resolutionScale,
initialViewportSourcePixels,
initialViewportPixelDensity
}
}
}, options)
const decodeDataUrl = (dataUrl) => {
const match = /^data:image\/webp;base64,(.+)$/i.exec(dataUrl)
if (!match) throw new Error('Renderer did not produce a WebP data URL')
return Buffer.from(match[1], 'base64')
}
const main = async () => {
const parsed = parseArgs(process.argv.slice(2))
if (parsed.help) {
console.log(usage)
return
}
const apiOrigin = normalizeOrigin(parsed.values.get('api') || process.env.GUIDE_BASEMAP_API_URL || DEFAULT_API_URL)
const apiRoot = resolveApiRoot(apiOrigin)
const mapId = parsed.values.get('map-id') || process.env.GUIDE_BASEMAP_MAP_ID || DEFAULT_MAP_ID
const outputDir = path.resolve(parsed.values.get('out-dir') || process.env.GUIDE_BASEMAP_OUTPUT_DIR || DEFAULT_OUTPUT_DIR)
const viteUrl = normalizeOrigin(parsed.values.get('vite-url') || process.env.GUIDE_BASEMAP_VITE_URL || DEFAULT_VITE_URL)
const width = asPositiveInteger(parsed.values.get('width') || process.env.GUIDE_BASEMAP_WIDTH || DEFAULT_WIDTH, '--width')
const height = asPositiveInteger(parsed.values.get('height') || process.env.GUIDE_BASEMAP_HEIGHT || DEFAULT_HEIGHT, '--height')
const padding = asPadding(parsed.values.get('padding') || process.env.GUIDE_BASEMAP_PADDING || DEFAULT_PADDING)
const requestedFloors = new Set([
...parsed.floorCodes,
...(process.env.GUIDE_BASEMAP_FLOOR || '').split(',').map((item) => item.trim()).filter(Boolean)
].map((item) => item.toUpperCase()))
const staticRoot = path.resolve(process.cwd(), 'static')
const outputRelative = path.relative(staticRoot, outputDir)
if (outputRelative.startsWith('..') || path.isAbsolute(outputRelative)) {
throw new Error(`--out-dir must be inside ${staticRoot}`)
}
await fetch(`${viteUrl}/node_modules/three/build/three.module.js`).then((response) => {
if (!response.ok) throw new Error(`Vite Three.js module is unavailable: ${viteUrl}`)
})
await mkdir(outputDir, { recursive: true })
const sdkManifest = await getJson(`${apiRoot}/gis/sdk/maps/${encodeURIComponent(mapId)}/manifest`)
const accessibleFloors = (sdkManifest.floors || []).filter((floor) => {
const code = String(floor.floorCode || '').trim().toUpperCase()
return Boolean(code && floor.modelUrl && (!requestedFloors.size || requestedFloors.has(code)))
})
const sdkFloorsByCode = new Map((sdkManifest.floors || []).map((floor) => (
[String(floor.floorCode || '').trim().toUpperCase(), floor]
)))
const compositeRouteAssets = (sdkManifest.routeAssets || [])
.filter((asset) => String(asset.assetRole || '').trim().toUpperCase() === 'SAME_GROUND_COMPOSITE')
.filter((asset) => {
const code = String(asset.floorCode || '').trim().toUpperCase()
return Boolean(code && asset.modelUrl && (!requestedFloors.size || requestedFloors.has(code)))
})
.map((asset) => {
const coverageFloorCodes = Array.from(new Set((asset.coverageFloorCodes || [])
.map((code) => String(code || '').trim().toUpperCase())
.filter(Boolean)))
const coverageFloors = coverageFloorCodes.map((code) => sdkFloorsByCode.get(code))
if (coverageFloorCodes.length <= 1 || coverageFloors.some((floor) => !floor)) {
throw new Error(`${asset.floorCode}: composite coverage cannot be resolved to SDK floors`)
}
return {
...asset,
floorCode: String(asset.floorCode).trim().toUpperCase(),
coverageFloorCodes,
coverageFloorIds: coverageFloors.map((floor) => String(floor.floorId))
}
})
const resources = [
...accessibleFloors.map((floor) => ({
kind: 'floor',
source: floor,
floorId: String(floor.floorId),
floorCode: String(floor.floorCode).trim().toUpperCase(),
floorName: floor.floorName || null,
sortOrder: floor.sortOrder ?? null,
modelUrl: floor.modelUrl,
modelVersion: String(floor.modelVersion || sdkManifest.dataVersion || 'unversioned')
})),
...compositeRouteAssets.map((asset) => ({
kind: 'route-asset',
source: asset,
assetId: String(asset.id || asset.floorCode),
role: 'SAME_GROUND_COMPOSITE',
floorId: String(asset.floorCode),
floorCode: String(asset.floorCode),
floorName: null,
sortOrder: asset.sortOrder ?? null,
modelUrl: asset.modelUrl,
modelVersion: String(asset.modelVersion || sdkManifest.dataVersion || 'unversioned'),
coverageFloorCodes: asset.coverageFloorCodes,
coverageFloorIds: asset.coverageFloorIds
}))
]
if (!resources.length) throw new Error('No accessible SDK floor or composite route assets matched the requested filters')
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 })
const virtualAssets = new Map()
await page.route('**/__guide-floor-basemap-assets__/*.glb', async (route) => {
const asset = virtualAssets.get(new URL(route.request().url()).pathname)
if (!asset) return route.abort()
await route.fulfill({
status: 200,
contentType: asset.contentType || 'model/gltf-binary',
body: asset.buffer
})
})
await page.goto(`${viteUrl}/`, { waitUntil: 'domcontentloaded' })
const floors = []
const routeAssets = []
try {
for (const resource of resources) {
const { floorCode, modelVersion } = resource
const modelUrl = resolveModelUrl(apiOrigin, resource.modelUrl)
const downloaded = await getBinary(modelUrl)
const assetPath = `/__guide-floor-basemap-assets__/${safeFilePart(floorCode)}.glb`
virtualAssets.set(assetPath, downloaded)
const rendered = await createRenderer(page, {
assetUrl: assetPath,
width,
height,
padding,
floorCode,
resourceKind: resource.kind,
decoderPath: `${viteUrl}/node_modules/three/examples/jsm/libs/draco/gltf/`,
cameraContract: {
yawDegrees: GUIDE_CAMERA_YAW_DEGREES,
fovDegrees: GUIDE_CAMERA_FOV_DEGREES,
overviewDistance: OVERVIEW_CAMERA_DISTANCE,
indoorReferenceFitDimension: INDOOR_REFERENCE_FIT_DIMENSION,
indoorDistanceFactor: INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR,
overviewMaxDistanceFactor: OVERVIEW_MAX_DISTANCE_FACTOR,
floorMaxDistanceFactor: FLOOR_MAX_DISTANCE_FACTOR,
overviewScreenOffsetRatioX: OVERVIEW_SCREEN_OFFSET_RATIO_X,
floorScreenOffsetRatioX: FLOOR_SCREEN_OFFSET_RATIO_X,
target: GUIDE_CAMERA_TARGET,
minimumInitialViewportCssWidth: MIN_INITIAL_VIEWPORT_CSS_WIDTH,
minimumInitialViewportPixelDensity: MIN_INITIAL_VIEWPORT_PIXEL_DENSITY
}
})
if (rendered.pixelStats.nonBackgroundRatio < 0.003) {
throw new Error(`${floorCode} rendered as an almost blank image (${rendered.pixelStats.nonBackgroundRatio})`)
}
if (rendered.output.initialViewportPixelDensity + 1e-6 < MIN_INITIAL_VIEWPORT_PIXEL_DENSITY) {
throw new Error(`${floorCode} initial viewport pixel density is too low (${rendered.output.initialViewportPixelDensity})`)
}
const fileName = `${safeFilePart(floorCode)}.${safeFilePart(modelVersion)}.${PROJECTION_VERSION}.${COMPOSITION_VERSION}.webp`
const outputFile = path.join(outputDir, fileName)
const image = decodeDataUrl(rendered.dataUrl)
if (image.length < 1024) throw new Error(`${floorCode} WebP output is unexpectedly small`)
if (image.length > MAX_WEBP_BYTES) {
throw new Error(`${floorCode} WebP exceeds the weak-network size budget (${image.length} bytes)`)
}
await writeFile(outputFile, image)
const entry = {
floorId: resource.floorId,
floorCode,
floorName: resource.floorName,
sortOrder: resource.sortOrder,
modelVersion,
modelUrl: resource.modelUrl,
glbBytes: downloaded.buffer.length,
bounds: rendered.bounds,
projection: rendered.projection,
initialViewport: rendered.initialViewport,
visualCenter: rendered.visualCenter,
image: {
path: toPublicPath(outputDir, fileName),
width: rendered.output.width,
height: rendered.output.height,
format: 'webp',
bytes: image.length,
nonBackgroundPixels: rendered.pixelStats.nonBackgroundPixels,
nonBackgroundRatio: rendered.pixelStats.nonBackgroundRatio,
resolutionScale: rendered.output.resolutionScale,
initialViewportSourcePixels: rendered.output.initialViewportSourcePixels,
initialViewportPixelDensityAt390CssPx: rendered.output.initialViewportPixelDensity
}
}
if (resource.kind === 'route-asset') {
routeAssets.push({
...entry,
assetId: resource.assetId,
role: resource.role,
coverageFloorCodes: resource.coverageFloorCodes,
coverageFloorIds: resource.coverageFloorIds
})
} else {
floors.push(entry)
}
virtualAssets.delete(assetPath)
console.log(`Rendered ${floorCode}: ${fileName} (${image.length} bytes)`)
}
} finally {
await browser.close()
}
const outputManifest = {
schemaVersion: 4,
generatedAt: new Date().toISOString(),
generator: 'scripts/generate-guide-floor-basemaps.mjs',
source: {
apiUrl: apiRoot,
mapId: String(sdkManifest.mapId || mapId),
mapName: sdkManifest.mapName || null,
dataVersion: sdkManifest.dataVersion || null,
coordinateSystem: sdkManifest.coordinateSystem || 'GLB_METER'
},
render: {
renderer: 'three.js GLTFLoader + DRACOLoader via Playwright',
projection: 'orthographic-xz',
projectionVersion: PROJECTION_VERSION,
width,
height,
padding,
cameraContract: {
yawDegrees: GUIDE_CAMERA_YAW_DEGREES,
fovDegrees: GUIDE_CAMERA_FOV_DEGREES,
overviewDistance: OVERVIEW_CAMERA_DISTANCE,
indoorReferenceFitDimension: INDOOR_REFERENCE_FIT_DIMENSION,
indoorDistanceFactor: INDOOR_ENTRY_CAMERA_DISTANCE_FACTOR,
overviewMaxDistanceFactor: OVERVIEW_MAX_DISTANCE_FACTOR,
floorMaxDistanceFactor: FLOOR_MAX_DISTANCE_FACTOR,
overviewScreenOffsetRatioX: OVERVIEW_SCREEN_OFFSET_RATIO_X,
floorScreenOffsetRatioX: FLOOR_SCREEN_OFFSET_RATIO_X,
target: GUIDE_CAMERA_TARGET
},
compositionVersion: COMPOSITION_VERSION,
minimumInitialViewportCssWidth: MIN_INITIAL_VIEWPORT_CSS_WIDTH,
minimumInitialViewportPixelDensity: MIN_INITIAL_VIEWPORT_PIXEL_DENSITY
},
floors,
routeAssets
}
await writeFile(path.join(outputDir, 'manifest.json'), `${JSON.stringify(outputManifest, null, 2)}\n`, 'utf8')
console.log(`Wrote ${floors.length} floor and ${routeAssets.length} composite basemap entries to ${path.join(outputDir, 'manifest.json')}`)
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error)
process.exitCode = 1
})

View File

@@ -0,0 +1,52 @@
const fs = require('fs')
const path = require('path')
const projectRoot = path.resolve(__dirname, '..')
const repoRoot = path.resolve(projectRoot, '..')
const sourceDir = path.join(repoRoot, 'sgs-map-sdk-release', 'engine')
const targetDir = path.join(projectRoot, 'public', 'engine')
if (!fs.existsSync(sourceDir)) {
throw new Error(`SDK Engine source not found: ${sourceDir}`)
}
const publicDir = path.join(projectRoot, 'public')
const resolvedTarget = path.resolve(targetDir)
if (!resolvedTarget.startsWith(path.resolve(publicDir) + path.sep)) {
throw new Error(`Refusing to write outside public directory: ${resolvedTarget}`)
}
const rewriteTextFiles = (dir) => {
const replacements = [
['http://1.92.206.90:9000/museum-assets', '/museum-assets'],
['http://1.92.206.90:9000/tts-audio', '/tts-audio'],
['http://1.92.206.90:9000', 'http://legacy-minio.local:9000'],
['https://1.92.206.90:9000', 'https://legacy-minio.local:9000'],
['https://guide.whaoyue.com/museum-assets', '/museum-assets'],
['https://guide.whaoyue.com/tts-audio', '/tts-audio'],
['https://guide.whaoyue.com/app-api', '/app-api']
]
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const filePath = path.join(dir, entry.name)
if (entry.isDirectory()) {
rewriteTextFiles(filePath)
continue
}
if (!entry.isFile() || !/\.(?:html|js|mjs|json|css|map)$/i.test(entry.name)) continue
let text = fs.readFileSync(filePath, 'utf8')
const original = text
for (const [from, to] of replacements) {
text = text.split(from).join(to)
}
if (text !== original) fs.writeFileSync(filePath, text, 'utf8')
}
}
fs.rmSync(targetDir, { recursive: true, force: true })
fs.mkdirSync(path.dirname(targetDir), { recursive: true })
fs.cpSync(sourceDir, targetDir, { recursive: true })
rewriteTextFiles(targetDir)
console.log(`Synced SDK Engine: ${sourceDir} -> ${targetDir}`)

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