3D 查看器容器宽高比变化时按新比例重算取景

- framing 新增 resolveModel3dViewerReframedCameraPosition:保留朝向与相对缩放并钳进新缩放范围
- scene 的 ResizeObserver 不再只认首次尺寸,宽高比变化超过 1% 就重算取景
- resetView 改为按容器当前宽高比重算,不再复用可能过期的取景
- 首帧直接套用创建时的取景,避免重复一次顶点采样
- 补 framing 测试:朝向保留、相对缩放保留、缩放钳制与方向退化
This commit is contained in:
2026-09-23 11:51:26 +08:00
parent f4083dcb6d
commit 5285832acb
3 changed files with 203 additions and 21 deletions
@@ -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,
);
});
});
+41
View File
@@ -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,
+71 -21
View File
@@ -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);
}