638 lines
26 KiB
JavaScript
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
|
|
})
|