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 SDK API origin (default: ${DEFAULT_API_URL}) --map-id SDK map id (default: ${DEFAULT_MAP_ID}) --out-dir Output directory (default: ${DEFAULT_OUTPUT_DIR}) --vite-url Existing Vite server URL (default: ${DEFAULT_VITE_URL}) --floor Render only floor code(s), comma separated or repeated --width Output width (default: ${DEFAULT_WIDTH}) --height Output height (default: ${DEFAULT_HEIGHT}) --padding 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 })