优化三维导览建筑内外自动切换
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-13 18:54:55 +08:00
parent d265089875
commit 9f642ca6ed
7 changed files with 964 additions and 96 deletions

View File

@@ -0,0 +1,189 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test'
interface CameraReport {
position: { x: number; y: number; z: number }
target: { x: number; y: number; z: number }
up: { x: number; y: number; z: number }
quaternion: { x: number; y: number; z: number; w: number }
fov: number
zoom: number
near: number
far: number
distance: number
}
interface AnchorReport {
id: string
world: { x: number; y: number; z: number }
screen: { x: number; y: number; z: number; visible: boolean }
}
interface VisualReport {
camera: CameraReport
modelRoot: {
positionError: number
rotationError: number
scaleError: number
maxError: number
} | null
anchors: AnchorReport[]
activeView: string
floorId: string
isCameraTweening: boolean
}
interface VisualStabilityApi {
getReport: () => VisualReport
getFloorIds: () => string[]
switchFloor: (floorId: string) => Promise<void>
showOverview: () => Promise<void>
}
const getApiState = async (page: Page) => {
try {
return await page.evaluate(() => {
const windowWithDiagnostics = window as Window & {
__GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi
}
const api = windowWithDiagnostics.__GUIDE_3D_VISUAL_STABILITY__
return api
? { floorIds: api.getFloorIds(), report: api.getReport() }
: null
})
} catch (error) {
if (error instanceof Error && error.message.includes('Execution context was destroyed')) {
return null
}
throw error
}
}
const getReport = async (page: Page) => {
const state = await getApiState(page)
expect(state).not.toBeNull()
return state!.report
}
const getMaximumCameraDelta = (before: CameraReport, after: CameraReport) => Math.max(
Math.hypot(
before.position.x - after.position.x,
before.position.y - after.position.y,
before.position.z - after.position.z
),
Math.hypot(
before.target.x - after.target.x,
before.target.y - after.target.y,
before.target.z - after.target.z
),
Math.hypot(before.up.x - after.up.x, before.up.y - after.up.y, before.up.z - after.up.z),
Math.hypot(
before.quaternion.x - after.quaternion.x,
before.quaternion.y - after.quaternion.y,
before.quaternion.z - after.quaternion.z,
before.quaternion.w - after.quaternion.w
),
Math.abs(before.fov - after.fov),
Math.abs(before.zoom - after.zoom),
Math.abs(before.near - after.near),
Math.abs(before.far - after.far),
Math.abs(before.distance - after.distance)
)
const getMaximumAnchorDeltaPx = (before: AnchorReport[], after: AnchorReport[]) => Math.max(
...before.map((anchor) => {
const next = after.find((candidate) => candidate.id === anchor.id)
expect(next, `missing ${anchor.id}`).toBeDefined()
return Math.hypot(anchor.screen.x - next!.screen.x, anchor.screen.y - next!.screen.y)
})
)
const assertStableTransition = (before: VisualReport, after: VisualReport) => {
expect(after.isCameraTweening).toBe(false)
expect(after.modelRoot, 'the active model must stay committed').not.toBeNull()
expect(after.modelRoot!.maxError).toBeLessThan(1e-12)
expect(getMaximumCameraDelta(before.camera, after.camera)).toBeLessThan(1e-6)
expect(after.anchors).toHaveLength(3)
expect(getMaximumAnchorDeltaPx(before.anchors, after.anchors)).toBeLessThanOrEqual(2)
}
const screenshot = async (page: Page, testInfo: TestInfo, name: string) => {
await page.screenshot({ path: testInfo.outputPath(`${name}.png`) })
}
const waitForTransition = async (
page: Page,
transition: Promise<void>,
testInfo: TestInfo,
name: string
) => {
let complete = false
void transition.then(() => { complete = true })
for (let frame = 0; !complete && frame < 180; frame += 1) {
await page.waitForTimeout(80)
const report = await getReport(page)
expect(report.modelRoot, `blank model frame ${frame}`).not.toBeNull()
if (frame === 0) {
await screenshot(page, testInfo, `${name}-during`)
}
}
await transition
expect(complete, `${name} exceeded the sampling window`).toBe(true)
}
const switchFloor = async (page: Page, testInfo: TestInfo, floorId: string, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
const transition = page.evaluate(async (id) => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
await api.switchFloor(id)
}, floorId)
await waitForTransition(page, transition, testInfo, name)
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
assertStableTransition(before, after)
}
const showOverview = async (page: Page, testInfo: TestInfo, name: string) => {
const before = await getReport(page)
await screenshot(page, testInfo, `${name}-before`)
const transition = page.evaluate(async () => {
const api = (window as Window & { __GUIDE_3D_VISUAL_STABILITY__?: VisualStabilityApi })
.__GUIDE_3D_VISUAL_STABILITY__
if (!api) throw new Error('ThreeMap visual stability diagnostics unavailable')
await api.showOverview()
})
await waitForTransition(page, transition, testInfo, name)
const after = await getReport(page)
await screenshot(page, testInfo, `${name}-after`)
assertStableTransition(before, after)
}
test('ordinary exterior and floor switches preserve the GLB_METER camera projection', async ({ page }, testInfo) => {
await page.goto('/')
await page.getByText('馆内', { exact: true }).click()
await expect.poll(async () => (await getApiState(page)) !== null, { timeout: 180_000 }).toBe(true)
await expect.poll(async () => (await getApiState(page))?.report.anchors.length ?? 0, {
timeout: 180_000
}).toBe(3)
const state = await getApiState(page)
expect(state?.floorIds).toEqual(expect.arrayContaining(['L-2', 'L1', 'L1.5', 'L2', 'L3', 'L5']))
await switchFloor(page, testInfo, 'L1', 'exterior-l1')
await switchFloor(page, testInfo, 'L2', 'l1-l2')
await switchFloor(page, testInfo, 'L3', 'l2-l3')
await switchFloor(page, testInfo, 'L1', 'l3-l1')
await showOverview(page, testInfo, 'l1-exterior')
await switchFloor(page, testInfo, 'L-2', 'exterior-lminus2')
await switchFloor(page, testInfo, 'L5', 'lminus2-l5')
await switchFloor(page, testInfo, 'L-2', 'l5-lminus2')
await showOverview(page, testInfo, 'lminus2-exterior')
await switchFloor(page, testInfo, 'L1.5', 'exterior-l1point5')
await showOverview(page, testInfo, 'l1point5-exterior')
})

View File

@@ -24,16 +24,25 @@ afterEach(() => {
})
describe('建筑外观与馆内楼层自动切换状态机', () => {
it('连续放大 14.9% 不进入,达到 15% 并保持 500ms 后进入', () => {
it('连续放大 14.9% 不进入,达到 15% 后进入', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'gesture')
machine.updateDistance(85.1, { source: 'gesture' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
machine.updateDistance(85, { source: 'gesture' })
vi.advanceTimersByTime(499)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
})
it('进入条件必须连续保持 120ms', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'wheel')
machine.updateDistance(85, { source: 'wheel' })
vi.advanceTimersByTime(119)
expect(requests).toEqual([])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['enter-floor'])
@@ -46,12 +55,12 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.updateDistance(95, { source: 'wheel' })
machine.beginInput(95, 'wheel')
machine.updateDistance(90, { source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
})
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 并保持 500ms 后退出', () => {
it('相对楼层初始距离拉远 19.9% 不退出,达到 20% 后退出', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
@@ -61,37 +70,59 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
expect(requests).toEqual([])
machine.updateDistance(120)
vi.advanceTimersByTime(499)
vi.advanceTimersByTime(400)
expect(requests).toEqual(['exit-overview'])
})
it('退出条件必须连续保持 400ms', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(120)
vi.advanceTimersByTime(399)
expect(requests).toEqual([])
vi.advanceTimersByTime(1)
expect(requests).toEqual(['exit-overview'])
})
it('条件满足后反向缩放会取消防抖任务并重置进入基准', () => {
it('0.8% 内的触控反向抖动不重置进入基准,也不取消候选状态', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'touch', { resetBaseline: true })
machine.updateDistance(85, { source: 'touch' })
vi.advanceTimersByTime(300)
machine.updateDistance(88, { source: 'touch' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(60)
machine.updateDistance(85.6, { source: 'touch' })
vi.advanceTimersByTime(60)
expect(requests).toEqual([])
machine.updateDistance(74.8, { source: 'touch' })
vi.advanceTimersByTime(500)
expect(requests).toEqual(['enter-floor'])
})
it('退出条件满足后回到 20% 阈值内会取消防抖任务', () => {
it('明确反向缩放超过 0.8% 后取消候选并重置进入基准', () => {
const { machine, requests } = createMachine()
machine.beginInput(100, 'touch', { resetBaseline: true })
machine.updateDistance(85, { source: 'touch' })
vi.advanceTimersByTime(60)
machine.updateDistance(86, { source: 'touch' })
vi.advanceTimersByTime(120)
expect(requests).toEqual([])
machine.updateDistance(73, { source: 'touch' })
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
})
it('退出候选明确反向超过 0.8% 后会取消防抖任务', () => {
const { machine, requests } = createMachine()
machine.setView('floor')
machine.setFloorInitialDistance(100)
machine.updateDistance(120)
vi.advanceTimersByTime(300)
machine.updateDistance(119.9)
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(200)
machine.updateDistance(118.9)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
})
@@ -101,13 +132,13 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.beginInput(100, 'wheel')
machine.updateDistance(90, { source: 'wheel' })
vi.advanceTimersByTime(1201)
vi.advanceTimersByTime(2001)
machine.updateDistance(80, { previousDistance: 90, source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual([])
machine.updateDistance(76.5, { source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
})
@@ -116,7 +147,7 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.beginInput(100, 'wheel')
machine.updateDistance(85, { source: 'wheel' })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
machine.markTransitionSucceeded()
@@ -136,11 +167,11 @@ describe('建筑外观与馆内楼层自动切换状态机', () => {
machine.beginInput(100, source, { resetBaseline: source === 'touch' })
machine.updateDistance(85.1, { source })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(400)
expect(requests).toEqual([])
machine.updateDistance(85, { source })
vi.advanceTimersByTime(500)
vi.advanceTimersByTime(120)
expect(requests).toEqual(['enter-floor'])
}
)