From 5285832acb608a9860a5048cf1b0affd9121e49c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 11:51:26 +0800 Subject: [PATCH] =?UTF-8?q?3D=20=E6=9F=A5=E7=9C=8B=E5=99=A8=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E5=AE=BD=E9=AB=98=E6=AF=94=E5=8F=98=E5=8C=96=E6=97=B6?= =?UTF-8?q?=E6=8C=89=E6=96=B0=E6=AF=94=E4=BE=8B=E9=87=8D=E7=AE=97=E5=8F=96?= =?UTF-8?q?=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - framing 新增 resolveModel3dViewerReframedCameraPosition:保留朝向与相对缩放并钳进新缩放范围 - scene 的 ResizeObserver 不再只认首次尺寸,宽高比变化超过 1% 就重算取景 - resetView 改为按容器当前宽高比重算,不再复用可能过期的取景 - 首帧直接套用创建时的取景,避免重复一次顶点采样 - 补 framing 测试:朝向保留、相对缩放保留、缩放钳制与方向退化 --- packages/model3d-viewer/src/framing.test.ts | 91 ++++++++++++++++++++ packages/model3d-viewer/src/framing.ts | 41 +++++++++ packages/model3d-viewer/src/scene.ts | 92 ++++++++++++++++----- 3 files changed, 203 insertions(+), 21 deletions(-) diff --git a/packages/model3d-viewer/src/framing.test.ts b/packages/model3d-viewer/src/framing.test.ts index a30c2f2ee..9ef05ad96 100644 --- a/packages/model3d-viewer/src/framing.test.ts +++ b/packages/model3d-viewer/src/framing.test.ts @@ -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, + ); + }); +}); diff --git a/packages/model3d-viewer/src/framing.ts b/packages/model3d-viewer/src/framing.ts index a9c35d926..13fdf3455 100644 --- a/packages/model3d-viewer/src/framing.ts +++ b/packages/model3d-viewer/src/framing.ts @@ -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, diff --git a/packages/model3d-viewer/src/scene.ts b/packages/model3d-viewer/src/scene.ts index 978c77467..60ea056b8 100644 --- a/packages/model3d-viewer/src/scene.ts +++ b/packages/model3d-viewer/src/scene.ts @@ -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); }