3D 查看器容器宽高比变化时按新比例重算取景
- framing 新增 resolveModel3dViewerReframedCameraPosition:保留朝向与相对缩放并钳进新缩放范围 - scene 的 ResizeObserver 不再只认首次尺寸,宽高比变化超过 1% 就重算取景 - resetView 改为按容器当前宽高比重算,不再复用可能过期的取景 - 首帧直接套用创建时的取景,避免重复一次顶点采样 - 补 framing 测试:朝向保留、相对缩放保留、缩放钳制与方向退化
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
MODEL3D_VIEWER_FIT_FRACTION,
|
||||
type Model3dViewerBox,
|
||||
resolveModel3dViewerReframedCameraPosition,
|
||||
} from './framing';
|
||||
|
||||
type Point = [number, number, number];
|
||||
@@ -209,3 +210,93 @@ describe('computeModel3dViewerBoxCenter', () => {
|
||||
).toEqual({ x: 0, y: 0, z: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveModel3dViewerReframedCameraPosition', () => {
|
||||
const wide = computeModel3dViewerFraming({
|
||||
box: box([-1, -1, -1], [1, 1, 1]),
|
||||
aspect: 2,
|
||||
});
|
||||
const narrow = computeModel3dViewerFraming({
|
||||
box: box([-1, -1, -1], [1, 1, 1]),
|
||||
aspect: 0.5,
|
||||
});
|
||||
|
||||
function resetCameraPosition(framing: typeof wide) {
|
||||
const { center, resetDirection, distance } = framing;
|
||||
return {
|
||||
x: center.x + resetDirection.x * distance,
|
||||
y: center.y + resetDirection.y * distance,
|
||||
z: center.z + resetDirection.z * distance,
|
||||
};
|
||||
}
|
||||
|
||||
function distanceTo(position: { x: number; y: number; z: number }) {
|
||||
return Math.hypot(
|
||||
position.x - narrow.center.x,
|
||||
position.y - narrow.center.y,
|
||||
position.z - narrow.center.z,
|
||||
);
|
||||
}
|
||||
|
||||
it('容器变窄后按新取景距离重算,朝向不变', () => {
|
||||
const position = resolveModel3dViewerReframedCameraPosition({
|
||||
previous: wide,
|
||||
next: narrow,
|
||||
cameraPosition: resetCameraPosition(wide),
|
||||
});
|
||||
|
||||
expect(narrow.distance).toBeGreaterThan(wide.distance);
|
||||
expect(distanceTo(position)).toBeCloseTo(narrow.distance, 6);
|
||||
expect(position.x - narrow.center.x).toBeCloseTo(
|
||||
narrow.resetDirection.x * narrow.distance,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it('保留用户的相对缩放', () => {
|
||||
const zoomedIn = resetCameraPosition(wide);
|
||||
const half = {
|
||||
x: wide.center.x + (zoomedIn.x - wide.center.x) / 2,
|
||||
y: wide.center.y + (zoomedIn.y - wide.center.y) / 2,
|
||||
z: wide.center.z + (zoomedIn.z - wide.center.z) / 2,
|
||||
};
|
||||
|
||||
const position = resolveModel3dViewerReframedCameraPosition({
|
||||
previous: wide,
|
||||
next: narrow,
|
||||
cameraPosition: half,
|
||||
});
|
||||
|
||||
expect(distanceTo(position)).toBeCloseTo(narrow.distance / 2, 6);
|
||||
});
|
||||
|
||||
it('超出新缩放范围时钳到上下限', () => {
|
||||
const far = {
|
||||
x: wide.center.x + wide.resetDirection.x * wide.distance * 100,
|
||||
y: wide.center.y + wide.resetDirection.y * wide.distance * 100,
|
||||
z: wide.center.z + wide.resetDirection.z * wide.distance * 100,
|
||||
};
|
||||
|
||||
const position = resolveModel3dViewerReframedCameraPosition({
|
||||
previous: wide,
|
||||
next: narrow,
|
||||
cameraPosition: far,
|
||||
});
|
||||
|
||||
expect(distanceTo(position)).toBeCloseTo(narrow.maxDistance, 6);
|
||||
});
|
||||
|
||||
it('相机落在取景中心(方向退化)时退回复位方向', () => {
|
||||
const position = resolveModel3dViewerReframedCameraPosition({
|
||||
previous: wide,
|
||||
next: narrow,
|
||||
cameraPosition: { ...wide.center },
|
||||
});
|
||||
|
||||
expect(distanceTo(position)).toBeCloseTo(narrow.distance, 6);
|
||||
expect(position.x - narrow.center.x).toBeCloseTo(
|
||||
narrow.resetDirection.x * narrow.distance,
|
||||
6,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,47 @@ export function computeModel3dViewerFitDistance(
|
||||
return solver.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* 容器宽高比变化后重算取景时,相机该待的位置。
|
||||
*
|
||||
* 保留用户当前的观察方向与相对缩放(当前距离 / 旧取景距离),只把相机沿原方向搬到
|
||||
* 新取景距离上,并钳进新的缩放范围,避免重算取景把用户转到的角度和缩放一起丢掉;
|
||||
* 方向退化(相机正好落在取景中心)时退回复位方向。
|
||||
*/
|
||||
export function resolveModel3dViewerReframedCameraPosition(input: {
|
||||
/** 变化前的取景。 */
|
||||
previous: Model3dViewerFraming;
|
||||
/** 按新宽高比重算后的取景。 */
|
||||
next: Model3dViewerFraming;
|
||||
cameraPosition: Model3dViewerVector3;
|
||||
}): Model3dViewerVector3 {
|
||||
const { previous, next, cameraPosition } = input;
|
||||
const offset = subtract(cameraPosition, previous.center);
|
||||
const offsetLength = Math.hypot(offset.x, offset.y, offset.z);
|
||||
// 相机正好落在取景中心时朝向与相对缩放都无从谈起,按复位视角处理。
|
||||
const hasOffset = offsetLength > Number.EPSILON;
|
||||
const direction = hasOffset
|
||||
? {
|
||||
x: offset.x / offsetLength,
|
||||
y: offset.y / offsetLength,
|
||||
z: offset.z / offsetLength,
|
||||
}
|
||||
: next.resetDirection;
|
||||
const ratio =
|
||||
hasOffset && previous.distance > Number.EPSILON
|
||||
? offsetLength / previous.distance
|
||||
: 1;
|
||||
const distance = Math.min(
|
||||
Math.max(next.distance * ratio, next.minDistance),
|
||||
next.maxDistance,
|
||||
);
|
||||
return {
|
||||
x: next.center.x + direction.x * distance,
|
||||
y: next.center.y + direction.y * distance,
|
||||
z: next.center.z + direction.z * distance,
|
||||
};
|
||||
}
|
||||
|
||||
/** 由包围盒推导取景、裁剪面与轨道缩放范围。 */
|
||||
export function computeModel3dViewerFraming(
|
||||
input: Model3dViewerFramingInput,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
computeModel3dViewerFraming,
|
||||
createModel3dViewerFitSolver,
|
||||
MODEL3D_VIEWER_CAMERA_FOV_DEGREES,
|
||||
type Model3dViewerFraming,
|
||||
resolveModel3dViewerReframedCameraPosition,
|
||||
} from './framing';
|
||||
import {
|
||||
describeModel3dViewerError,
|
||||
@@ -27,6 +29,8 @@ const DAMPING_FACTOR = 0.08;
|
||||
const AUTO_ROTATE_SPEED = 1.6;
|
||||
/** 单个几何最多采样多少顶点,避免超大模型在打开瞬间卡住主线程。 */
|
||||
const MAX_FIT_SAMPLES_PER_GEOMETRY = 200_000;
|
||||
/** 容器宽高比相对变化超过这个比例才重算取景:轻微抖动不必打断用户当前视角。 */
|
||||
const FRAMING_ASPECT_TOLERANCE = 0.01;
|
||||
|
||||
export type Model3dViewerScene = {
|
||||
resetView(): void;
|
||||
@@ -301,6 +305,7 @@ export async function createModel3dViewerScene(
|
||||
}
|
||||
|
||||
let framing = computeFraming(viewport.aspect);
|
||||
let framedAspect = viewport.aspect;
|
||||
let hasFramedViewport = viewport.width > 0 && viewport.height > 0;
|
||||
|
||||
function measureViewport(): {
|
||||
@@ -328,42 +333,87 @@ export async function createModel3dViewerScene(
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
camera.near = framing.near;
|
||||
camera.far = framing.far;
|
||||
/**
|
||||
* 把取景套到相机与轨道控制器上。`preserveCamera` 时保留用户当前的观察方向与相对
|
||||
* 缩放,只把相机沿原方向搬到新取景距离上;否则回到取景复位视角。
|
||||
*/
|
||||
function applyFraming(next: Model3dViewerFraming, preserveCamera: boolean) {
|
||||
const position = preserveCamera
|
||||
? resolveModel3dViewerReframedCameraPosition({
|
||||
previous: framing,
|
||||
next,
|
||||
cameraPosition: {
|
||||
x: camera.position.x,
|
||||
y: camera.position.y,
|
||||
z: camera.position.z,
|
||||
},
|
||||
})
|
||||
: {
|
||||
x: next.center.x + next.resetDirection.x * next.distance,
|
||||
y: next.center.y + next.resetDirection.y * next.distance,
|
||||
z: next.center.z + next.resetDirection.z * next.distance,
|
||||
};
|
||||
camera.near = next.near;
|
||||
camera.far = next.far;
|
||||
camera.updateProjectionMatrix();
|
||||
orbitControls.minDistance = framing.minDistance;
|
||||
orbitControls.maxDistance = framing.maxDistance;
|
||||
orbitControls.target.set(
|
||||
framing.center.x,
|
||||
framing.center.y,
|
||||
framing.center.z,
|
||||
);
|
||||
camera.position.set(
|
||||
framing.center.x + framing.resetDirection.x * framing.distance,
|
||||
framing.center.y + framing.resetDirection.y * framing.distance,
|
||||
framing.center.z + framing.resetDirection.z * framing.distance,
|
||||
);
|
||||
orbitControls.minDistance = next.minDistance;
|
||||
orbitControls.maxDistance = next.maxDistance;
|
||||
orbitControls.target.set(next.center.x, next.center.y, next.center.z);
|
||||
camera.position.set(position.x, position.y, position.z);
|
||||
orbitControls.update();
|
||||
framing = next;
|
||||
}
|
||||
|
||||
resetView();
|
||||
/**
|
||||
* 宽高比确实变化时按新比例重算取景并套用:比例没变(同比例缩放容器)就保留现有取景,
|
||||
* 不打断用户当前视角。返回是否重算了取景。
|
||||
*/
|
||||
function reframeIfAspectChanged(aspect: number, preserveCamera: boolean) {
|
||||
if (
|
||||
hasFramedViewport &&
|
||||
Math.abs(aspect - framedAspect) <= framedAspect * FRAMING_ASPECT_TOLERANCE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
applyFraming(computeFraming(aspect), preserveCamera);
|
||||
framedAspect = aspect;
|
||||
hasFramedViewport = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复位视角:按容器当前宽高比重算取景,不复用可能过期的 `framing` —— 容器变窄后旧取景的
|
||||
* 距离与缩放上下限都不再贴合,跟着旧取景复位会把模型裁掉或放得过小。
|
||||
*/
|
||||
function resetView() {
|
||||
const current = measureViewport();
|
||||
const measurable = current.width > 0 && current.height > 0;
|
||||
// 容器量不到尺寸(隐藏面板、首帧)时只能按现有取景复位,等量到尺寸再重算。
|
||||
applyFraming(measurable ? computeFraming(current.aspect) : framing, false);
|
||||
if (measurable) {
|
||||
framedAspect = current.aspect;
|
||||
hasFramedViewport = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧按创建时的比例(容器量不到尺寸时是方形兜底)套一次取景;真实比例由下面的
|
||||
// ResizeObserver 首轮回调重算,避免在这里重复一次顶点采样。
|
||||
applyFraming(framing, false);
|
||||
applyViewport();
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
applyViewport();
|
||||
if (disposed || hasFramedViewport) {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
// 首次拿到非零尺寸:按真实比例重算取景并复位,用户此后的缩放不再被覆盖。
|
||||
const current = measureViewport();
|
||||
if (current.width === 0 || current.height === 0) {
|
||||
return;
|
||||
}
|
||||
hasFramedViewport = true;
|
||||
framing = computeFraming(current.aspect);
|
||||
resetView();
|
||||
// 首次拿到非零尺寸:按真实比例重算取景并复位,用户此后的缩放不再被覆盖。
|
||||
// 此后只有宽高比确实变了(可缩放的面板)才重算,且保留用户当前的朝向与相对缩放。
|
||||
reframeIfAspectChanged(current.aspect, hasFramedViewport);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user