Files
frontend-miniapp/scripts/generate-guide-floor-basemaps.mjs
lyf 540bb8627f
Some checks failed
CI / verify (push) Has been cancelled
同步移动端源码并修复小程序嵌入标题
2026-07-28 18:37:40 +08:00

638 lines
26 KiB
JavaScript

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 selectedFloors = (sdkManifest.floors || []).filter((floor) => {
const code = String(floor.floorCode || '').trim().toUpperCase()
return Boolean(code && (!requestedFloors.size || requestedFloors.has(code)))
})
const accessibleFloors = (await Promise.all(selectedFloors.map(async (floor) => {
if (floor.modelUrl) return floor
const bundle = await getJson(
`${apiRoot}/gis/sdk/floors/${encodeURIComponent(String(floor.floorId))}/bundle`
)
const modelUrl = String(bundle?.model?.modelUrl || '').trim()
if (!modelUrl) {
throw new Error(`${floor.floorCode}: SDK manifest and floor bundle both lack a model URL`)
}
return {
...floor,
modelUrl,
modelVersion: String(
bundle?.model?.modelVersion || bundle?.dataVersion || floor.modelVersion || sdkManifest.dataVersion || 'unversioned'
)
}
}))).filter(Boolean)
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
})