diff --git a/.env.development b/.env.development index 0b243f1..31263c9 100644 --- a/.env.development +++ b/.env.development @@ -24,9 +24,10 @@ VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST= VITE_PUBLIC_LEGACY_AUDIO_HOST= # Vite 开发服务器代理目标,只由 vite.config.ts 读取。 -DEV_PROXY_APP_API_TARGET=http://1.92.206.90:3001 -DEV_PROXY_ENGINE_TARGET=http://1.92.206.90:3001 -DEV_PROXY_SDK_TARGET=http://1.92.206.90:3001 +# /engine 由 public/engine 本地静态资源提供,保持和大屏端一致,不再代理到地图管理端。 +DEV_PROXY_APP_API_TARGET=http://localhost:48080 +DEV_PROXY_ENGINE_TARGET= +DEV_PROXY_SDK_TARGET= DEV_PROXY_MUSEUM_ASSETS_TARGET=http://1.92.206.90:9000 -DEV_PROXY_MINIO_TARGET=http://1.92.206.90:3001 -DEV_PROXY_AUDIO_TARGET=http://1.92.206.90:19000 +DEV_PROXY_MINIO_TARGET=http://localhost:48080 +DEV_PROXY_AUDIO_TARGET=http://localhost:19000 diff --git a/.env.test b/.env.test index 6629b93..a8cedd3 100644 --- a/.env.test +++ b/.env.test @@ -16,7 +16,8 @@ VITE_SGS_API_BASE_URL=/app-api VITE_SGS_MAP_ID=1 VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js?v=2.5.0 VITE_SGS_H5_ENGINE_URL=/engine/index.html -VITE_SGS_SDK_ORIGIN=https://guide.whaoyue.com +# 测试构建使用同源 Engine;避免把历史测试域名写入产物。 +VITE_SGS_SDK_ORIGIN= VITE_SGS_SDK_TIMEOUT_MS=30000 VITE_TENCENT_MAP_KEY=__REPLACE_WITH_TENCENT_MAP_WEB_KEY__ VITE_PUBLIC_SAME_ORIGIN_ASSET_HOST= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3aaf752..7e9ae80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: pnpm lint - name: Build H5 - run: pnpm build:test:h5 + run: pnpm build:h5 - name: Build WeChat Mini Program run: pnpm build:mp-weixin diff --git a/README.md b/README.md index ce845e8..0d7293f 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ VITE_SGS_API_BASE_URL=/app-api VITE_AUDIO_API_BASE_URL=/yudao-server VITE_AUDIO_LANGUAGE=zh-CN -# SGS SDK/H5 地图基座配置;当前代码尚未把 SDK renderer 接入页面渲染 +# SGS SDK/H5 地图基座配置;/engine 由本项目 public/engine 静态资源提供 VITE_SGS_MAP_ID=1 VITE_SGS_SDK_SCRIPT_URL=/static/sgs-map-sdk/index.global.js?v=2.5.0 VITE_SGS_H5_ENGINE_URL=/engine/index.html @@ -142,6 +142,9 @@ VITE_SGS_SDK_TIMEOUT_MS=5000 ```bash pnpm install +# 同步本地 SDK Engine 静态底座 +pnpm sync:sdk-engine + # H5 开发 pnpm dev:h5 @@ -177,7 +180,7 @@ uni build -p h5 && node scripts/copy-h5-nav-assets.cjs - `dist/build/h5` 存在应用产物。 - H5 可访问 `static/nav-assets/...` 下的 GLB/GLTF/bin/texture/manifest 文件。 - H5 可访问 `static/guide-data` 下的讲解和内容数据。 -- 如启用 `api` 或 `sdk` 模式,Nginx/网关需代理 `/app-api`、`/yudao-server`、`/engine` 等路径。`/engine/index.html` 必须是 SGS Map SDK Engine 2.5.x 的独立发布页面,且其资源路径可加载并能完成 `HELLO` -> `ENGINE_READY`;业务 SPA fallback 返回的 200 不是 Engine 健康。 +- 如启用 `api` 或 `sdk` 模式,Nginx/网关需代理 `/app-api`、`/yudao-server` 等后端路径。`/engine/index.html` 由本项目静态资源独立提供,必须是 SGS Map SDK Engine 2.5.x 的发布页面,且其资源路径可加载并能完成 `HELLO` -> `ENGINE_READY`;业务 SPA fallback 返回的 200 不是 Engine 健康。 更完整的部署说明见 `docs/H5_DEPLOYMENT_GUIDE.md`。 diff --git a/docs/H5_DEPLOYMENT_GUIDE.md b/docs/H5_DEPLOYMENT_GUIDE.md index 1590576..e5369cf 100644 --- a/docs/H5_DEPLOYMENT_GUIDE.md +++ b/docs/H5_DEPLOYMENT_GUIDE.md @@ -24,6 +24,17 @@ pnpm type-check pnpm build:h5 ``` +生产构建必须通过部署环境变量注入真实腾讯地图 Web Key,Key 不提交到仓库: + +```powershell +$env:VITE_TENCENT_MAP_KEY = '<真实腾讯地图 Web Key>' +pnpm build:h5 +Remove-Item Env:VITE_TENCENT_MAP_KEY +``` + +构建脚本会拒绝 Key 占位符、历史测试域名和旧 MinIO 地址;测试构建可使用 +`pnpm build:test:h5`,该命令只允许保留地图 Key 占位符,不允许旧域名进入产物。 + `pnpm build:h5` 会先执行 `uni build -p h5`,再执行 `scripts/copy-h5-nav-assets.cjs`,把 `static/nav-assets` 复制到 `dist/build/h5/static/nav-assets`。 构建后重点检查: @@ -95,6 +106,39 @@ location ^~ /static/nav-assets/ { try_files $uri =404; } +# Vite 产物文件名带内容 hash,可以长时间缓存且不会污染新版本。 +location ^~ /assets/ { + expires 365d; + add_header Cache-Control "public, max-age=31536000, immutable"; + try_files $uri =404; +} + +# SDK、Engine、讲解图片和音频使用带版本/日期的路径,缓存 30 天。 +location = /engine/index.html { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; +} + +location ^~ /engine/ { + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + try_files $uri =404; +} + +location ^~ /static/ { + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + try_files $uri =404; +} + +# HTML 负责指向最新 hash 资源,必须及时重新验证。 +location = /index.html { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; +} + location / { try_files $uri $uri/ /index.html; } diff --git a/index.html b/index.html index 547fb07..41ea3f9 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - 深圳自然博物馆智能导览 + 深圳自然博物馆地图导览
diff --git a/package.json b/package.json index 5cf61e8..ed50580 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,20 @@ "node": ">=20 <25" }, "scripts": { + "sync:sdk-engine": "node scripts/sync-sdk-engine.cjs", + "predev:h5": "npm run sync:sdk-engine", "dev:h5": "uni -p h5 --mode development", + "predev:test:h5": "npm run sync:sdk-engine", "dev:test:h5": "uni -p h5 --mode test", - "build:test:h5": "uni build -p h5 --mode test && node scripts/finalize-h5-build.cjs --allow-placeholder", - "build:h5": "uni build -p h5 --mode production && node scripts/finalize-h5-build.cjs --require-tencent-map-key", - "build:h5:no-minify": "uni build -p h5 --mode production --minify false && node scripts/finalize-h5-build.cjs --require-tencent-map-key", + "build:test:h5": "npm run sync:sdk-engine && uni build -p h5 --mode test && node scripts/finalize-h5-build.cjs --allow-placeholder", + "build:h5": "npm run sync:sdk-engine && uni build -p h5 --mode production && node scripts/finalize-h5-build.cjs --require-tencent-map-key", + "build:h5:no-minify": "npm run sync:sdk-engine && uni build -p h5 --mode production --minify false && node scripts/finalize-h5-build.cjs --require-tencent-map-key", "dev:mp-weixin": "uni -p mp-weixin --mode development", "dev:test:mp-weixin": "uni -p mp-weixin --mode test", "build:test:mp-weixin": "uni build -p mp-weixin --mode test", "build:mp-weixin": "uni build -p mp-weixin --mode production", + "generate:guide-floor-basemaps": "node scripts/generate-guide-floor-basemaps.mjs", + "verify:guide-floor-basemaps": "node scripts/verify-guide-floor-basemaps.mjs", "verify:arrival-build": "node scripts/verify-arrival-navigation-build.cjs", "test:unit": "vitest run", "test:e2e:camera-stability": "playwright test tests/e2e/three-map-camera-stability.spec.ts", diff --git a/scripts/copy-h5-nav-assets.cjs b/scripts/copy-h5-nav-assets.cjs index 70d4f05..da593b1 100644 --- a/scripts/copy-h5-nav-assets.cjs +++ b/scripts/copy-h5-nav-assets.cjs @@ -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) diff --git a/scripts/finalize-h5-build.cjs b/scripts/finalize-h5-build.cjs index 9e46f13..b2fca7d 100644 --- a/scripts/finalize-h5-build.cjs +++ b/scripts/finalize-h5-build.cjs @@ -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))) diff --git a/scripts/generate-guide-floor-basemaps.mjs b/scripts/generate-guide-floor-basemaps.mjs new file mode 100644 index 0000000..60b4a7f --- /dev/null +++ b/scripts/generate-guide-floor-basemaps.mjs @@ -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 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 +}) diff --git a/scripts/sync-sdk-engine.cjs b/scripts/sync-sdk-engine.cjs new file mode 100644 index 0000000..117b5f8 --- /dev/null +++ b/scripts/sync-sdk-engine.cjs @@ -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}`) diff --git a/scripts/verify-guide-floor-basemaps.mjs b/scripts/verify-guide-floor-basemaps.mjs new file mode 100644 index 0000000..4071a54 --- /dev/null +++ b/scripts/verify-guide-floor-basemaps.mjs @@ -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 ] [--vite-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 +}) diff --git a/src/components/explain/ExplainGuideStopCatalog.vue b/src/components/explain/ExplainGuideStopCatalog.vue index 32c618d..639259f 100644 --- a/src/components/explain/ExplainGuideStopCatalog.vue +++ b/src/components/explain/ExplainGuideStopCatalog.vue @@ -9,13 +9,26 @@ - 正在加载讲解对象稍后将展示该展厅的讲解对象。 + 讲解对象加载失败{{ error }} diff --git a/src/components/map/ThreeMap.vue b/src/components/map/ThreeMap.vue index 7f502d3..894a532 100644 --- a/src/components/map/ThreeMap.vue +++ b/src/components/map/ThreeMap.vue @@ -1,9 +1,44 @@