|
|
@@ -1,20 +1,22 @@
|
|
|
/**
|
|
|
- * T03 逻辑检查:三键相机契约与旋转中心规则(票 03 / ADR 0008)。
|
|
|
+ * T03 逻辑检查:三键相机契约、旋转中心规则与阻尼(票 03 / ADR 0008)。
|
|
|
*
|
|
|
* 仓库没有测试运行器,且项目约定不为验证引入新框架,所以这里用零依赖 Node 断言脚本:
|
|
|
* 相机语义全在 `domain/camera.js`(纯数学)与 `services/cameraControl.js`(状态迁移,引擎依赖注入),
|
|
|
* 因此可以用**假引擎**驱动整套交互,不需要浏览器、不需要 cesium。
|
|
|
- * 真机行为(Cesium 默认输入确实被替换、中心确实落在屏幕中心)由 t03-camera-walkthrough.mjs 覆盖。
|
|
|
+ * 真机行为(Cesium 默认输入确实被替换、中心确实落在屏幕中心、阻尼真的在真机上生效)
|
|
|
+ * 由 t03-camera-walkthrough.mjs 覆盖。
|
|
|
*
|
|
|
* 运行:node .scratch/cockpit-shell-panel-layout/verify/t03-camera-contract.mjs
|
|
|
*/
|
|
|
import assert from 'node:assert/strict'
|
|
|
-import { FOCUS_FLIGHT_MS, PITCH_LIMITS, RANGE_LIMITS } from '../../../src/modules/cockpit/domain/camera.js'
|
|
|
+import { DAMPING_TAU_MS, FOCUS_FLIGHT_MS, PITCH_LIMITS, RANGE_LIMITS } from '../../../src/modules/cockpit/domain/camera.js'
|
|
|
import * as camera from '../../../src/modules/cockpit/domain/camera.js'
|
|
|
import { attachCameraInput, createCameraControl } from '../../../src/modules/cockpit/services/cameraControl.js'
|
|
|
|
|
|
const GROUND = { lon: 110.39, lat: 28.46, height: 0 }
|
|
|
const XIANGLING = { lon: 110.396, lat: 28.455, height: 0 } // 沅陵某监测点
|
|
|
+const FRAME_MS = 1000 / 60
|
|
|
|
|
|
function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math.PI / 3, ground = GROUND } = {}) {
|
|
|
const applied = []
|
|
|
@@ -51,9 +53,6 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
|
|
|
requestFrame(callback) {
|
|
|
pendingFrame = callback
|
|
|
},
|
|
|
- cancelFrame() {
|
|
|
- pendingFrame = null
|
|
|
- },
|
|
|
},
|
|
|
get pendingFrame() {
|
|
|
return pendingFrame
|
|
|
@@ -61,12 +60,13 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
|
|
|
advance(ms) {
|
|
|
clock += ms
|
|
|
},
|
|
|
- runFrame() {
|
|
|
+ /** 跑一帧:假的 rAF 不带时间戳,控制器会退回 `engine.now()`(真机用真时间戳)。 */
|
|
|
+ runFrame(dtMs = FRAME_MS) {
|
|
|
const callback = pendingFrame
|
|
|
pendingFrame = null
|
|
|
+ clock += dtMs
|
|
|
if (callback) callback()
|
|
|
- },
|
|
|
- last() {
|
|
|
+ }, last() {
|
|
|
return applied[applied.length - 1]
|
|
|
},
|
|
|
}
|
|
|
@@ -74,12 +74,23 @@ function createFakeEngine({ viewport = { width: 1680, height: 900 }, fovY = Math
|
|
|
|
|
|
function newControl(options) {
|
|
|
const fake = createFakeEngine(options)
|
|
|
- const control = createCameraControl({ engine: fake.engine })
|
|
|
+ const control = createCameraControl({ engine: fake.engine, ...(options && options.control) })
|
|
|
control.syncFromEngine()
|
|
|
fake.applied.length = 0
|
|
|
return { fake, control }
|
|
|
}
|
|
|
|
|
|
+/** 一直跑到停稳(阻尼 + 换中心飞行都收敛),返回跑过的帧数。 */
|
|
|
+function settle(fake, control, maxFrames = 400) {
|
|
|
+ let frames = 0
|
|
|
+ while (!control.getState().settled && frames < maxFrames) {
|
|
|
+ fake.runFrame()
|
|
|
+ frames += 1
|
|
|
+ }
|
|
|
+ assert.ok(frames < maxFrames, `阻尼/飞行应在有限帧内停稳(已跑 ${frames} 帧仍未 settled)`)
|
|
|
+ return frames
|
|
|
+}
|
|
|
+
|
|
|
/** 假元素:记录监听器,供 attachCameraInput 的绑定/解绑断言。 */
|
|
|
function createFakeElement() {
|
|
|
const handlers = new Map()
|
|
|
@@ -113,6 +124,11 @@ function createFakeElement() {
|
|
|
const near = (a, b, tolerance, message) =>
|
|
|
assert.ok(Math.abs(a - b) <= tolerance, `${message}:期望 ≈${b}(±${tolerance}),实际 ${a}`)
|
|
|
|
|
|
+const round3 = (n) => Number(n.toFixed(3))
|
|
|
+
|
|
|
+const metersPerPxAt = (range) => camera.metersPerPixel({ range, fovYRad: Math.PI / 3, viewportHeight: 900 })
|
|
|
+const degreesLonFor = (meters, lat) => meters / (111320 * Math.cos((lat * Math.PI) / 180))
|
|
|
+
|
|
|
// ---------- 1. 左键拖拽只平移 ----------
|
|
|
{
|
|
|
const { fake, control } = newControl()
|
|
|
@@ -122,29 +138,33 @@ const near = (a, b, tolerance, message) =>
|
|
|
|
|
|
const after = control.getState()
|
|
|
assert.deepEqual(after.hpr, before.hpr, '平移不得改 heading / pitch / range')
|
|
|
- assert.equal(fake.last().hpr.range, before.hpr.range, '平移不得改弹簧臂长度')
|
|
|
|
|
|
- // 期望位移:range*tan(fov/2)*2/viewportH 米/像素 × 100 像素,heading=0 时应落在正东。
|
|
|
- const metersPerPx = camera.metersPerPixel({ range: 10000, fovYRad: Math.PI / 3, viewportHeight: 900 })
|
|
|
- const expectedLon = before.pivot.lon + (100 * metersPerPx) / (111320 * Math.cos((GROUND.lat * Math.PI) / 180))
|
|
|
- near(after.pivot.lon, expectedLon, Math.abs(expectedLon - before.pivot.lon) * 0.02, 'heading=0 时右拖应把中心按像素比例移到正东')
|
|
|
+ // 维护者 2026-09-17 裁定:**抓着地图拖** —— 拖右 → 相机左移、画面右移,所以中心往西(lon 减小)。
|
|
|
+ const expectedLon = before.pivot.lon - degreesLonFor(100 * metersPerPxAt(before.hpr.range), GROUND.lat)
|
|
|
+ near(after.pivot.lon, expectedLon, Math.abs(expectedLon - before.pivot.lon) * 0.02, '拖右应把中心按像素比例西移(相机左移)')
|
|
|
near(after.pivot.lat, before.pivot.lat, 1e-6, 'heading=0 时右拖不应产生南北位移')
|
|
|
- console.log('PASS 1 左键拖拽只平移(heading/pitch/range 原样,中心按像素比例东移)')
|
|
|
+
|
|
|
+ settle(fake, control)
|
|
|
+ near(fake.last().pivot.lon, expectedLon, 1e-9, '阻尼收敛后下发给相机的中心应等于目标')
|
|
|
+ console.log('PASS 1 左键拖拽只平移(heading/pitch/range 原样,中心按像素比例随「抓地图」方向西移)')
|
|
|
}
|
|
|
|
|
|
-// ---------- 2. 平移方向 = 相机移动方向(PAN_FOLLOWS_DRAG 的语义钉子) ----------
|
|
|
+// ---------- 2. 平移方向 = 抓着地图拖(维护者裁定的语义钉子) ----------
|
|
|
{
|
|
|
- const { control } = newControl()
|
|
|
+ const { fake, control } = newControl()
|
|
|
control.beginDrag({ button: 0, x: 0, y: 0 })
|
|
|
control.moveDrag({ x: 0, y: 100 })
|
|
|
const down = control.getState()
|
|
|
- assert.ok(down.pivot.lat < GROUND.lat, `heading=0 时下拖应把中心往南带(朝相机方向):${JSON.stringify(down.pivot)}`)
|
|
|
+ assert.ok(
|
|
|
+ down.pivot.lat > GROUND.lat,
|
|
|
+ `heading=0 时下拖应把中心往北带(画面下移、相机上移):${JSON.stringify(down.pivot)}`,
|
|
|
+ )
|
|
|
|
|
|
- const { control: control2 } = newControl()
|
|
|
+ const { fake: fake2, control: control2 } = newControl()
|
|
|
control2.beginDrag({ button: 2, x: 0, y: 0 })
|
|
|
- // 先把 heading 拖到 90°(向东看):像素数按灵敏度反算,不写死倍数。
|
|
|
- control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 })
|
|
|
+ control2.moveDrag({ x: 90 / camera.ORBIT_SENSITIVITY.heading, y: 0 }) // 先把 heading 拖到 90°(向东看)
|
|
|
control2.endDrag()
|
|
|
+ settle(fake2, control2)
|
|
|
near(control2.getState().hpr.heading, 90, 1e-6, '轨道旋转应把 heading 拖到 90°')
|
|
|
|
|
|
const beforePan = control2.getState()
|
|
|
@@ -152,11 +172,11 @@ const near = (a, b, tolerance, message) =>
|
|
|
control2.moveDrag({ x: 100, y: 0 })
|
|
|
const afterPan = control2.getState()
|
|
|
assert.ok(
|
|
|
- afterPan.pivot.lat < beforePan.pivot.lat,
|
|
|
- `heading=90°(向东看)时右拖应把中心往南带(相机的右手方向):${JSON.stringify(afterPan.pivot)}`,
|
|
|
+ afterPan.pivot.lat > beforePan.pivot.lat,
|
|
|
+ `heading=90°(向东看)时拖右应把中心往北带(画面右移=相机往左=北):${JSON.stringify(afterPan.pivot)}`,
|
|
|
)
|
|
|
near(afterPan.pivot.lon, beforePan.pivot.lon, 1e-6, 'heading=90° 时右拖不应产生东西位移')
|
|
|
- console.log('PASS 2 平移方向 = 相机移动方向(ADR 0008 的「拖拽方向映射为摄像机移动方向」,heading=0/90 两点验证)')
|
|
|
+ console.log('PASS 2 平移方向 = 抓着地图拖(拖右→画面右移/相机左移,heading=0/90 两点验证)')
|
|
|
}
|
|
|
|
|
|
// ---------- 3. 右键拖拽只做轨道旋转 ----------
|
|
|
@@ -171,18 +191,23 @@ const near = (a, b, tolerance, message) =>
|
|
|
near(after.hpr.pitch, -60 - 50 * 0.22, 1e-6, 'pitch 变化 = -dy × 灵敏度')
|
|
|
assert.equal(after.hpr.range, before.hpr.range, '轨道旋转不得改弹簧臂长度')
|
|
|
assert.deepEqual(after.pivot, before.pivot, '轨道旋转不得移动中心')
|
|
|
- assert.equal(fake.last().hpr.range, before.hpr.range, '下发给引擎的 range 也必须原样')
|
|
|
+
|
|
|
+ settle(fake, control)
|
|
|
+ near(fake.last().hpr.range, before.hpr.range, 1e-9, '阻尼收敛后下发的 range 仍应原样')
|
|
|
console.log('PASS 3 右键拖拽只做轨道旋转(heading/pitch 按像素变化,range 与中心不动)')
|
|
|
}
|
|
|
|
|
|
// ---------- 4. pitch 夹在上下限内 ----------
|
|
|
{
|
|
|
- const { control } = newControl()
|
|
|
+ const { fake, control } = newControl()
|
|
|
control.beginDrag({ button: 2, x: 0, y: 0 })
|
|
|
control.moveDrag({ x: 0, y: 5000 })
|
|
|
near(control.getState().hpr.pitch, PITCH_LIMITS.ground.min, 1e-9, '一直下拖应夹在下限')
|
|
|
control.moveDrag({ x: 0, y: -10000 })
|
|
|
near(control.getState().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '一直上拖应夹在上限')
|
|
|
+
|
|
|
+ settle(fake, control)
|
|
|
+ near(fake.last().hpr.pitch, PITCH_LIMITS.ground.max, 1e-9, '阻尼收敛后下发的 pitch 也应在限内')
|
|
|
console.log(`PASS 4 pitch 夹在 [${PITCH_LIMITS.ground.min}, ${PITCH_LIMITS.ground.max}](地面视图)`)
|
|
|
}
|
|
|
|
|
|
@@ -195,24 +220,20 @@ const near = (a, b, tolerance, message) =>
|
|
|
// 拖拽进行中来了「选中某对象」:中心不许被改,飞行也不许插进来。
|
|
|
control.setFocus(XIANGLING)
|
|
|
control.moveDrag({ x: 40, y: 0 })
|
|
|
- assert.deepEqual(fake.last().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
|
|
|
+ assert.deepEqual(control.getState().pivot, locked, '拖拽中下发的中心必须仍是按下时锁定的那个')
|
|
|
|
|
|
control.endDrag()
|
|
|
assert.equal(control.getState().flying, true, '松手后应开始平滑换中心')
|
|
|
- let guard = 0
|
|
|
- while (control.getState().flying && guard++ < 200) {
|
|
|
- fake.advance(50)
|
|
|
- fake.runFrame()
|
|
|
- }
|
|
|
+ settle(fake, control)
|
|
|
const final = control.getState().pivot
|
|
|
- near(final.lon, XIANGLING.lon, 1e-6, '飞行结束后中心应为选中对象')
|
|
|
- near(final.lat, XIANGLING.lat, 1e-6, '飞行结束后中心应为选中对象')
|
|
|
+ near(final.lon, XIANGLING.lon, 1e-9, '飞行结束后中心应为选中对象')
|
|
|
+ near(final.lat, XIANGLING.lat, 1e-9, '飞行结束后中心应为选中对象')
|
|
|
console.log('PASS 5 右键按下锁定中心(拖拽中的选中不插队;松手后才开始换中心)')
|
|
|
}
|
|
|
|
|
|
// ---------- 6. 滚轮只改弹簧臂长度 ----------
|
|
|
{
|
|
|
- const { control } = newControl()
|
|
|
+ const { fake, control } = newControl()
|
|
|
const before = control.getState()
|
|
|
const handled = control.wheel({ deltaY: -100 })
|
|
|
const after = control.getState()
|
|
|
@@ -227,6 +248,9 @@ const near = (a, b, tolerance, message) =>
|
|
|
near(control.getState().hpr.range, RANGE_LIMITS.min, 1e-9, '拉近到极限应夹住下限')
|
|
|
control.wheel({ deltaY: 1e6 })
|
|
|
near(control.getState().hpr.range, RANGE_LIMITS.max, 1e-9, '推远到极限应夹住上限')
|
|
|
+
|
|
|
+ settle(fake, control)
|
|
|
+ near(fake.last().hpr.range, RANGE_LIMITS.max, 1e-9, '阻尼收敛后下发的 range 应等于目标')
|
|
|
console.log(`PASS 6 滚轮只改弹簧臂长度(并夹在 [${RANGE_LIMITS.min}, ${RANGE_LIMITS.max}] 米)`)
|
|
|
}
|
|
|
|
|
|
@@ -236,11 +260,7 @@ const near = (a, b, tolerance, message) =>
|
|
|
assert.deepEqual(control.getState().pivot, GROUND, '未选中时中心 = 屏幕中心的地面拾取点')
|
|
|
|
|
|
control.setFocus(XIANGLING)
|
|
|
- let guard = 0
|
|
|
- while (control.getState().flying && guard++ < 200) {
|
|
|
- fake.advance(60)
|
|
|
- fake.runFrame()
|
|
|
- }
|
|
|
+ settle(fake, control)
|
|
|
assert.deepEqual(control.getState().pivot, XIANGLING, '选中后中心 = 对象地表点')
|
|
|
|
|
|
control.setFocus(null)
|
|
|
@@ -261,13 +281,13 @@ const near = (a, b, tolerance, message) =>
|
|
|
control.setFocus(XIANGLING)
|
|
|
|
|
|
// 起手第一帧(elapsed = 0)必须仍停在原中心 —— 换中心不许瞬移。
|
|
|
- near(fake.last().pivot.lon, before.pivot.lon, 1e-12, '换中心第一帧应从原中心出发')
|
|
|
+ fake.runFrame(0)
|
|
|
+ near(control.getState().pivot.lon, before.pivot.lon, 1e-12, '换中心第一帧应从原中心出发')
|
|
|
|
|
|
const samples = []
|
|
|
- let guard = 0
|
|
|
- while (control.getState().flying && guard++ < 200) {
|
|
|
- fake.advance(FOCUS_FLIGHT_MS / 10)
|
|
|
- fake.runFrame()
|
|
|
+ let frames = 0
|
|
|
+ while (control.getState().flying && frames++ < 200) {
|
|
|
+ fake.runFrame(FOCUS_FLIGHT_MS / 8)
|
|
|
const now = control.getState()
|
|
|
assert.equal(now.hpr.heading, before.hpr.heading, '换中心过程中 heading 必须保持')
|
|
|
assert.equal(now.hpr.range, before.hpr.range, '换中心过程中 range 必须保持')
|
|
|
@@ -275,17 +295,122 @@ const near = (a, b, tolerance, message) =>
|
|
|
}
|
|
|
|
|
|
assert.ok(samples.length >= 4, `换中心应有多帧(实际 ${samples.length})`)
|
|
|
- const monotonic = samples.every((lon, i) => i === 0 || lon >= samples[i - 1] - 1e-12)
|
|
|
- assert.equal(monotonic, true, `中心应朝目标单调推进:${samples.join(' → ')}`)
|
|
|
- assert.ok(samples[0] > before.pivot.lon && samples[0] < XIANGLING.lon, '第一帧应落在起止之间')
|
|
|
- near(samples[samples.length - 1], XIANGLING.lon, 1e-6, '末帧应到达目标中心')
|
|
|
+ assert.equal(
|
|
|
+ samples.every((lon, i) => i === 0 || lon >= samples[i - 1] - 1e-12),
|
|
|
+ true,
|
|
|
+ `中心应朝目标单调推进:${samples.join(' → ')}`,
|
|
|
+ )
|
|
|
+ settle(fake, control)
|
|
|
+ near(control.getState().pivot.lon, XIANGLING.lon, 1e-9, '末帧应到达目标中心')
|
|
|
assert.equal(control.getState().flying, false, '到达后应结束飞行')
|
|
|
console.log(`PASS 8 换中心平滑(${samples.length} 帧单调推进、heading/range 全程不变)`)
|
|
|
}
|
|
|
|
|
|
-// ---------- 9. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
|
|
|
+// ---------- 9. 阻尼:渐入、不超调、停稳、且与帧率无关 ----------
|
|
|
{
|
|
|
- const { control } = newControl()
|
|
|
+ const { fake, control } = newControl()
|
|
|
+ const before = control.getState()
|
|
|
+ control.beginDrag({ button: 0, x: 0, y: 0 })
|
|
|
+ control.moveDrag({ x: 400, y: 0 })
|
|
|
+ const target = control.getState()
|
|
|
+
|
|
|
+ // 目标立刻到位(输入不丢),实际态还在起点附近 —— 这就是「丝滑」的来源。
|
|
|
+ assert.notDeepEqual(target.pivot, before.pivot, '目标态应当立刻跟上输入')
|
|
|
+ assert.deepEqual(control.getState().shownPivot, before.pivot, '首个阻尼帧之前,实际态还在起点')
|
|
|
+
|
|
|
+ const targetShift = target.pivot.lon - before.pivot.lon
|
|
|
+ fake.runFrame(FRAME_MS)
|
|
|
+ const oneFrame = control.getState()
|
|
|
+ const oneFrameShift = oneFrame.shownPivot.lon - before.pivot.lon
|
|
|
+ assert.ok(
|
|
|
+ Math.abs(oneFrameShift) > 0 && Math.abs(oneFrameShift) < Math.abs(targetShift) * 0.6,
|
|
|
+ `一帧内应只走一小部分(实际 ${oneFrameShift} / 目标 ${targetShift})`,
|
|
|
+ )
|
|
|
+ assert.equal(oneFrame.settled, false, '一帧后还没停稳')
|
|
|
+
|
|
|
+ // 逼近过程不得超调(单调朝目标,不越过)。
|
|
|
+ let previous = Math.abs(target.pivot.lon - oneFrame.shownPivot.lon)
|
|
|
+ let frames = 1
|
|
|
+ while (!control.getState().settled && frames < 400) {
|
|
|
+ fake.runFrame(FRAME_MS)
|
|
|
+ const gap = Math.abs(target.pivot.lon - control.getState().shownPivot.lon)
|
|
|
+ assert.ok(gap <= previous + 1e-12, `阻尼不得超调:缺口从 ${previous} 变成 ${gap}`)
|
|
|
+ previous = gap
|
|
|
+ frames += 1
|
|
|
+ }
|
|
|
+ assert.equal(control.getState().settled, true, '有限帧内应停稳')
|
|
|
+ assert.deepEqual(control.getState().shownPivot, target.pivot, '停稳后实际态应吸附到目标')
|
|
|
+ console.log(`PASS 9a 阻尼渐入、不超调、${frames} 帧内停稳并吸附(τ=${DAMPING_TAU_MS}ms)`)
|
|
|
+
|
|
|
+ // 帧率无关:同样的真实时长,60fps 与 120fps 收敛到同一个位置(±1%)。
|
|
|
+ const walk = (dtMs, totalMs) => {
|
|
|
+ const { fake: f, control: c } = newControl()
|
|
|
+ const start = c.getState()
|
|
|
+ c.beginDrag({ button: 0, x: 0, y: 0 })
|
|
|
+ c.moveDrag({ x: 400, y: 0 })
|
|
|
+ // 固定帧数,别用浮点累加判停:累加误差会多跑一帧,把「帧率无关」测成假的偏差。
|
|
|
+ const frames = Math.round(totalMs / dtMs)
|
|
|
+ for (let i = 0; i < frames; i += 1) f.runFrame(dtMs)
|
|
|
+ const shift = c.getState().shownPivot.lon - start.pivot.lon
|
|
|
+ const full = c.getState().pivot.lon - start.pivot.lon
|
|
|
+ return shift / full
|
|
|
+ }
|
|
|
+ const at60 = walk(1000 / 60, 200)
|
|
|
+ const at120 = walk(1000 / 120, 200)
|
|
|
+ near(at60, at120, 0.01, '同样 200ms 内 60fps 与 120fps 的收敛比例应一致(±1%)')
|
|
|
+ assert.ok(at60 > 0.6 && at60 < 0.95, `200ms(≈1.8τ)应走掉大半但未走完:实际 ${at60}`)
|
|
|
+ console.log(`PASS 9b 阻尼与帧率无关(60fps ${(at60 * 100).toFixed(1)}% vs 120fps ${(at120 * 100).toFixed(1)}%)`)
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 10. 用户操作打断换中心飞行 ----------
|
|
|
+{
|
|
|
+ const { fake, control } = newControl()
|
|
|
+ control.setFocus(XIANGLING)
|
|
|
+ assert.equal(control.getState().flying, true, '应处于换中心飞行中')
|
|
|
+
|
|
|
+ control.beginDrag({ button: 0, x: 0, y: 0 })
|
|
|
+ assert.equal(control.getState().flying, false, '一上手就应打断飞行(输入永远优先)')
|
|
|
+ console.log('PASS 10 用户操作打断换中心飞行')
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 10b. 阻尼可现场拖调(手感是主观的,维护者要能在控制台试) ----------
|
|
|
+{
|
|
|
+ const { fake, control } = newControl()
|
|
|
+ assert.equal(control.getState().dampingTauMs, DAMPING_TAU_MS, '默认应取 domain 里的 τ')
|
|
|
+
|
|
|
+ // 调大 τ:同样一帧走得更少(更飘)。
|
|
|
+ const shiftWith = (tau) => {
|
|
|
+ const { fake: f, control: c } = newControl()
|
|
|
+ c.setDampingTau(tau)
|
|
|
+ const start = c.getState().pivot.lon
|
|
|
+ c.beginDrag({ button: 0, x: 0, y: 0 })
|
|
|
+ c.moveDrag({ x: 400, y: 0 })
|
|
|
+ f.runFrame(FRAME_MS)
|
|
|
+ return Math.abs(c.getState().shownPivot.lon - start)
|
|
|
+ }
|
|
|
+ const loose = shiftWith(400)
|
|
|
+ const tight = shiftWith(40)
|
|
|
+ assert.ok(tight > loose * 1.5, `τ 越小越跟手:τ=40 走 ${tight},τ=400 只走 ${loose}`)
|
|
|
+
|
|
|
+ // τ = 0:不做阻尼(一帧内直接贴到目标),给「要完全即时手感」的场合留的档。
|
|
|
+ const { fake: f2, control: c2 } = newControl()
|
|
|
+ c2.setDampingTau(0)
|
|
|
+ c2.beginDrag({ button: 0, x: 0, y: 0 })
|
|
|
+ c2.moveDrag({ x: 400, y: 0 })
|
|
|
+ f2.runFrame(FRAME_MS)
|
|
|
+ assert.deepEqual(c2.getState().shownPivot, c2.getState().pivot, 'τ=0 时一帧内实际态应贴到目标态')
|
|
|
+ assert.equal(c2.getState().settled, true, 'τ=0 时下完指令一帧即停稳')
|
|
|
+ assert.equal(f2.pendingFrame, null, 'τ=0 停稳后不应再排帧')
|
|
|
+
|
|
|
+ // 非法值不生效(守住旋钮)。
|
|
|
+ assert.equal(control.setDampingTau(-5), DAMPING_TAU_MS, '负数应被拒绝并保持原值')
|
|
|
+ assert.equal(control.setDampingTau('abc'), DAMPING_TAU_MS, '非数字应被拒绝并保持原值')
|
|
|
+ console.log(`PASS 10b 阻尼可拖调(τ=40 走 ${round3(tight)}° > τ=400 走 ${round3(loose)}°;τ=0 直接跟手)`)
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 11. DOM 绑定:三类输入都接上,右键菜单与滚轮默认行为被吃掉 ----------
|
|
|
+{
|
|
|
+ const { fake, control } = newControl()
|
|
|
const element = createFakeElement()
|
|
|
const detach = attachCameraInput(element, control)
|
|
|
|
|
|
@@ -295,13 +420,11 @@ const near = (a, b, tolerance, message) =>
|
|
|
'应绑定左右键拖拽与滚轮(并吃掉右键菜单)',
|
|
|
)
|
|
|
|
|
|
- const menu = element.fire('contextmenu')
|
|
|
- assert.equal(menu.defaultPrevented, true, '右键菜单必须被 preventDefault')
|
|
|
+ assert.equal(element.fire('contextmenu').defaultPrevented, true, '右键菜单必须被 preventDefault')
|
|
|
|
|
|
element.fire('pointerdown', { button: 0, pointerId: 1, clientX: 500, clientY: 400 })
|
|
|
element.fire('pointermove', { pointerId: 1, clientX: 560, clientY: 400 })
|
|
|
- const dragging = control.getState()
|
|
|
- assert.equal(dragging.dragging, 'pan', '左键按下后应处于平移态')
|
|
|
+ assert.equal(control.getState().dragging, 'pan', '左键按下后应处于平移态')
|
|
|
element.fire('pointerup', { pointerId: 1, clientX: 560, clientY: 400 })
|
|
|
assert.equal(control.getState().dragging, null, '松手后应结束拖拽')
|
|
|
|
|
|
@@ -310,24 +433,26 @@ const near = (a, b, tolerance, message) =>
|
|
|
assert.equal(control.getState().dragging, 'orbit', '右键按下后应处于轨道旋转态')
|
|
|
element.fire('pointerup', { pointerId: 2, clientX: 540, clientY: 400 })
|
|
|
|
|
|
- const wheelEvent = element.fire('wheel', { deltaY: -120 })
|
|
|
- assert.equal(wheelEvent.defaultPrevented, true, '滚轮默认行为(页面滚动)必须被吃掉')
|
|
|
+ assert.equal(element.fire('wheel', { deltaY: -120 }).defaultPrevented, true, '滚轮默认行为(页面滚动)必须被吃掉')
|
|
|
|
|
|
detach()
|
|
|
assert.equal(element.handlers.get('pointerdown').size, 0, 'detach 应摘掉监听器')
|
|
|
assert.equal(element.handlers.get('wheel').size, 0, 'detach 应摘掉滚轮监听器')
|
|
|
- console.log('PASS 9 DOM 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
|
|
|
+ console.log('PASS 11 DOM 绑定:三类输入接上、右键菜单与滚轮默认行为被吃掉、可完整摘除')
|
|
|
}
|
|
|
|
|
|
-// ---------- 10. 状态串(走查与人工排查读的那一行) ----------
|
|
|
+// ---------- 12. 状态串(走查与人工排查读的那一行) ----------
|
|
|
{
|
|
|
const { control } = newControl()
|
|
|
const text = camera.formatCameraState(control.getState())
|
|
|
- ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none'].forEach((part) =>
|
|
|
+ ;['h=', 'p=', 'r=', 'pivot=', 'focus=ground', 'drag=none', 'settled='].forEach((part) =>
|
|
|
assert.ok(text.includes(part), `状态串应含 ${part}:${text}`),
|
|
|
)
|
|
|
- assert.equal(camera.formatCameraState({ hpr: { heading: 0, pitch: -60, range: 1 }, pivot: null }), 'h=0.0 p=-60.0 r=1 pivot=none')
|
|
|
- console.log(`PASS 10 状态串可读:${text}`)
|
|
|
+ assert.equal(
|
|
|
+ camera.formatCameraState({ hpr: { heading: 0, pitch: -60, range: 1 }, pivot: null }),
|
|
|
+ 'h=0.0 p=-60.0 r=1 pivot=none',
|
|
|
+ )
|
|
|
+ console.log(`PASS 12 状态串可读:${text}`)
|
|
|
}
|
|
|
|
|
|
-console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、DOM 绑定均符合 ADR 0008。')
|
|
|
+console.log('\nT03 逻辑检查通过:三键契约、中心规则、换中心动画、阻尼(帧率无关)、DOM 绑定均符合契约与维护者裁定。')
|