336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION,
|
|
GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
findGuideCompositeBasemap,
|
|
findGuideFloorBasemap,
|
|
getGuideFloorBasemapFrame,
|
|
loadGuideFloorBasemapManifest,
|
|
projectPointToGuideFloorBasemap,
|
|
resetGuideFloorBasemapManifestCacheForTests,
|
|
isCompatibleGuideFloorBasemapManifest,
|
|
isValidGuideFloorOrthographicProjection,
|
|
unprojectGuideFloorBasemapPoint,
|
|
type GuideCompositeBasemapEntry,
|
|
type GuideFloorBasemapEntry
|
|
} from '@/services/model/GuideFloorBasemap'
|
|
|
|
const yaw = 54 * Math.PI / 180
|
|
const screenRight = { x: Math.cos(yaw), z: -Math.sin(yaw) }
|
|
const screenDown = { x: Math.sin(yaw), z: Math.cos(yaw) }
|
|
|
|
const createBasemap = (): GuideFloorBasemapEntry => ({
|
|
floorId: 'L1',
|
|
floorCode: 'L1',
|
|
modelVersion: 'v1',
|
|
projection: {
|
|
type: 'orthographic-xz',
|
|
minX: -43.737275,
|
|
maxX: 43.737275,
|
|
minZ: -47.055327,
|
|
maxZ: 47.055327,
|
|
planeY: 6,
|
|
center: { x: 0, z: 0 },
|
|
width: 80,
|
|
height: 50,
|
|
screenRight,
|
|
screenDown
|
|
},
|
|
initialViewport: {
|
|
centerX: 0,
|
|
centerZ: 0,
|
|
visibleWorldSpan: 40,
|
|
maxVisibleWorldSpan: 64
|
|
},
|
|
image: {
|
|
path: '/static/guide-floor-basemaps/L1.v1.ortho-yaw54-v2.compact-floor-center-v1.webp',
|
|
width: 1600,
|
|
height: 1000,
|
|
format: 'webp'
|
|
}
|
|
})
|
|
|
|
const createCompositeBasemap = (): GuideCompositeBasemapEntry => ({
|
|
...createBasemap(),
|
|
assetId: 'route-asset-1',
|
|
role: 'SAME_GROUND_COMPOSITE',
|
|
floorId: 'EXTERIOR_L1',
|
|
floorCode: 'EXTERIOR_L1',
|
|
modelVersion: 'composite-v1',
|
|
modelUrl: '/app-api/composite.glb',
|
|
coverageFloorCodes: ['EXTERIOR', 'L1'],
|
|
coverageFloorIds: ['exterior-id', 'l1-id'],
|
|
image: {
|
|
...createBasemap().image,
|
|
path: '/static/guide-floor-basemaps/EXTERIOR_L1.composite-v1.ortho-yaw54-v2.compact-floor-center-v1.webp'
|
|
}
|
|
})
|
|
|
|
describe('GuideFloorBasemap projection', () => {
|
|
afterEach(() => {
|
|
resetGuideFloorBasemapManifestCacheForTests()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('round-trips a GLB point through the orthographic WebP projection', () => {
|
|
const basemap = createBasemap()
|
|
const frame = getGuideFloorBasemapFrame(390, 844, 1600, 1000, 'cover')
|
|
const source = { x: 18, y: 0, z: -22 }
|
|
|
|
const projected = projectPointToGuideFloorBasemap(source, basemap, frame)
|
|
const restored = unprojectGuideFloorBasemapPoint(projected, basemap, frame)
|
|
|
|
expect(restored.x).toBeCloseTo(source.x, 6)
|
|
expect(restored.z).toBeCloseTo(source.z, 6)
|
|
})
|
|
|
|
it('maps projection-basis corners to the displayed image corners', () => {
|
|
const basemap = createBasemap()
|
|
const frame = getGuideFloorBasemapFrame(390, 844, 1600, 1000, 'contain')
|
|
const projection = basemap.projection!
|
|
const toWorld = (right: number, down: number) => ({
|
|
x: projection.center.x + right * projection.screenRight.x + down * projection.screenDown.x,
|
|
y: 999,
|
|
z: projection.center.z + right * projection.screenRight.z + down * projection.screenDown.z
|
|
})
|
|
const topLeft = projectPointToGuideFloorBasemap(
|
|
toWorld(-projection.width / 2, -projection.height / 2),
|
|
basemap,
|
|
frame
|
|
)
|
|
const bottomRight = projectPointToGuideFloorBasemap(
|
|
toWorld(projection.width / 2, projection.height / 2),
|
|
basemap,
|
|
frame
|
|
)
|
|
|
|
expect(topLeft.x).toBeCloseTo(frame.offsetX, 8)
|
|
expect(topLeft.y).toBeCloseTo(frame.offsetY, 8)
|
|
expect(bottomRight.x).toBeCloseTo(frame.offsetX + frame.width, 8)
|
|
expect(bottomRight.y).toBeCloseTo(frame.offsetY + frame.height, 8)
|
|
})
|
|
|
|
it('rejects a non-orthogonal projection basis', () => {
|
|
const projection = {
|
|
...createBasemap().projection!,
|
|
screenDown: { ...screenRight }
|
|
}
|
|
|
|
expect(isValidGuideFloorOrthographicProjection(projection)).toBe(false)
|
|
})
|
|
|
|
it('ignores POI height in the orthographic projection', () => {
|
|
const basemap = createBasemap()
|
|
const frame = getGuideFloorBasemapFrame(750, 1500, 750, 1500)
|
|
|
|
expect(projectPointToGuideFloorBasemap({ x: 5, y: -100, z: 8 }, basemap, frame))
|
|
.toEqual(projectPointToGuideFloorBasemap({ x: 5, y: 100, z: 8 }, basemap, frame))
|
|
})
|
|
|
|
it('revalidates the manifest instead of force-caching an obsolete schema', async () => {
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => ({
|
|
schemaVersion: 3,
|
|
render: {
|
|
projection: 'orthographic-xz',
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [],
|
|
routeAssets: []
|
|
})
|
|
}))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await loadGuideFloorBasemapManifest()
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/static/guide-floor-basemaps/manifest.json',
|
|
{ cache: 'no-cache' }
|
|
)
|
|
})
|
|
|
|
it('rejects an obsolete projection identity and can recover after cache reset', async () => {
|
|
const basemap = createBasemap()
|
|
const fetchMock = vi.fn()
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: 'orthographic-xz',
|
|
projectionVersion: 'oblique-v0'
|
|
},
|
|
floors: [basemap],
|
|
routeAssets: []
|
|
})
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: 'orthographic-xz',
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [basemap],
|
|
routeAssets: []
|
|
})
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const obsolete = await loadGuideFloorBasemapManifest()
|
|
expect(obsolete).toBeNull()
|
|
expect(findGuideFloorBasemap({
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: 'orthographic-xz',
|
|
projectionVersion: 'oblique-v0'
|
|
},
|
|
floors: [basemap],
|
|
routeAssets: []
|
|
}, 'L1')).toBeNull()
|
|
|
|
const recovered = await loadGuideFloorBasemapManifest()
|
|
expect(findGuideFloorBasemap(recovered, 'L1')).toEqual(basemap)
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('rejects the previous ortho-v1 cache identity', () => {
|
|
expect(isCompatibleGuideFloorBasemapManifest({
|
|
schemaVersion: 3,
|
|
render: {
|
|
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
projectionVersion: 'ortho-v1'
|
|
},
|
|
floors: [createBasemap()],
|
|
routeAssets: []
|
|
})).toBe(false)
|
|
})
|
|
|
|
it('rejects a schema 2 oblique manifest even when it contains a legacy camera projection', async () => {
|
|
const basemap = createBasemap()
|
|
const legacyBasemap: GuideFloorBasemapEntry = {
|
|
...basemap,
|
|
projection: undefined,
|
|
cameraProjection: {
|
|
type: 'perspective',
|
|
position: { x: 0, y: 100, z: 100 },
|
|
target: { x: 0, y: 0, z: 0 },
|
|
up: { x: 0, y: 1, z: 0 },
|
|
fov: 45,
|
|
near: 0.1,
|
|
far: 1000,
|
|
aspect: 1.6
|
|
}
|
|
}
|
|
const legacyManifest = {
|
|
schemaVersion: 2,
|
|
floors: [legacyBasemap],
|
|
routeAssets: []
|
|
}
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
json: async () => legacyManifest
|
|
}))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
expect(findGuideFloorBasemap(legacyManifest, 'L1')).toBeNull()
|
|
expect(await loadGuideFloorBasemapManifest()).toBeNull()
|
|
})
|
|
|
|
it('uses the sole valid floor basemap when the published GLB version is newer', () => {
|
|
const basemap = createBasemap()
|
|
const manifest = {
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [basemap],
|
|
routeAssets: []
|
|
}
|
|
|
|
expect(findGuideFloorBasemap(manifest, 'L1', 'v2')).toEqual(basemap)
|
|
})
|
|
|
|
it('does not guess a basemap when a floor has multiple nonmatching versions', () => {
|
|
const older = createBasemap()
|
|
const newer = { ...createBasemap(), modelVersion: 'v2' }
|
|
const manifest = {
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [older, newer],
|
|
routeAssets: []
|
|
}
|
|
|
|
expect(findGuideFloorBasemap(manifest, 'L1', 'v3')).toBeNull()
|
|
})
|
|
|
|
it('requires an orthographic floor entry under the schema 4 render identity', () => {
|
|
const basemap = createBasemap()
|
|
const manifest = {
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [{
|
|
...basemap,
|
|
projection: undefined,
|
|
cameraProjection: {
|
|
type: 'perspective' as const,
|
|
position: { x: 0, y: 100, z: 100 },
|
|
target: { x: 0, y: 0, z: 0 },
|
|
up: { x: 0, y: 1, z: 0 },
|
|
fov: 45,
|
|
near: 0.1,
|
|
far: 1000,
|
|
aspect: 1.6
|
|
}
|
|
}],
|
|
routeAssets: []
|
|
}
|
|
|
|
expect(findGuideFloorBasemap(manifest, 'L1')).toBeNull()
|
|
})
|
|
|
|
it('selects only the strict composite whose coverage equals the unique route floor set', () => {
|
|
const composite = createCompositeBasemap()
|
|
const manifest = {
|
|
schemaVersion: GUIDE_FLOOR_BASEMAP_SCHEMA_VERSION,
|
|
render: {
|
|
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [createBasemap()],
|
|
routeAssets: [composite]
|
|
}
|
|
|
|
expect(findGuideCompositeBasemap(manifest, ['l1-id', 'exterior-id', 'l1-id']))
|
|
.toEqual(composite)
|
|
expect(findGuideCompositeBasemap(manifest, ['L1', 'EXTERIOR'])).toEqual(composite)
|
|
expect(findGuideCompositeBasemap(manifest, ['l1-id'])).toBeNull()
|
|
expect(findGuideCompositeBasemap(manifest, ['l1-id', 'exterior-id', 'L2'])).toBeNull()
|
|
})
|
|
|
|
it('rejects schema 3 manifests instead of silently treating an ordinary exterior image as composite', () => {
|
|
const manifest = {
|
|
schemaVersion: 3,
|
|
render: {
|
|
projection: GUIDE_FLOOR_BASEMAP_PROJECTION,
|
|
projectionVersion: GUIDE_FLOOR_BASEMAP_PROJECTION_VERSION
|
|
},
|
|
floors: [createBasemap()],
|
|
routeAssets: [createCompositeBasemap()]
|
|
}
|
|
|
|
expect(isCompatibleGuideFloorBasemapManifest(manifest)).toBe(false)
|
|
expect(findGuideCompositeBasemap(manifest, ['L1', 'EXTERIOR'])).toBeNull()
|
|
})
|
|
})
|