diff --git a/packages/model3d-viewer/src/framing.test.ts b/packages/model3d-viewer/src/framing.test.ts index 9c22589d8..a119b0ed5 100644 --- a/packages/model3d-viewer/src/framing.test.ts +++ b/packages/model3d-viewer/src/framing.test.ts @@ -203,6 +203,48 @@ describe('createModel3dViewerFitSolver', () => { expect(Number.isFinite(fit)).toBe(true); expect(fit).toBeGreaterThan(0); }); + + it('带上新比例可复用同一份顶点样本重算取景距离', () => { + const rod: Point[] = [ + [-1, -1, -1], + [1, 1, 1], + ]; + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(CUBE), + aspect: 2, + fallbackRadius: computeModel3dViewerBoxRadius(CUBE), + }); + for (const [x, y, z] of rod) { + solver.add(x, y, z); + } + + const wide = solver.resolve(2); + const narrow = solver.resolve(0.5); + + expect(wide).toBeCloseTo(solveForPoints(CUBE, rod, 2), 6); + expect(narrow).toBeCloseTo(solveForPoints(CUBE, rod, 0.5), 6); + expect(narrow).toBeGreaterThan(wide); + // 复用样本:再问一次同一个比例,结果不变。 + expect(solver.resolve(2)).toBe(wide); + }); + + it('顶点里出现非有限值时整次取景回落到与尺度相关的默认距离', () => { + const solver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(CUBE), + aspect: 1, + fallbackRadius: computeModel3dViewerBoxRadius(CUBE), + }); + solver.add(Number.NaN, 0, 0); + solver.add(1, 1, 1); + // 没有可用顶点时的同一口径:退化取景距离只由包围盒尺度决定。 + const withoutSamples = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(CUBE), + aspect: 1, + fallbackRadius: computeModel3dViewerBoxRadius(CUBE), + }).resolve(1); + + expect(solver.resolve(1)).toBeCloseTo(withoutSamples, 6); + }); }); describe('computeModel3dViewerFraming', () => { diff --git a/packages/model3d-viewer/src/framing.ts b/packages/model3d-viewer/src/framing.ts index 1104f4731..fd8dbc5a4 100644 --- a/packages/model3d-viewer/src/framing.ts +++ b/packages/model3d-viewer/src/framing.ts @@ -52,7 +52,12 @@ export type Model3dViewerFitSolverInput = { export type Model3dViewerFitSolver = { add(x: number, y: number, z: number): void; - resolve(): number; + /** + * 取景距离。`aspect` 省略时用创建时的比例;带上新比例就能在**不重新采顶点**的前提下重算 —— + * 顶点投影出来的三个量(上下向、左右向、深度)与容器宽高比无关,只有左右向的视野上限随比例 + * 变化,所以容器变窄变宽只需要在这一份样本上重取一次最大值。 + */ + resolve(aspect?: number): number; }; export type Model3dViewerFraming = { @@ -186,26 +191,67 @@ export function createModel3dViewerFitSolver( const halfFovY = (fovDegrees * Math.PI) / 360; const limitY = fitFraction * Math.tan(halfFovY); - const limitX = limitY * aspect; - - let required = 0; const fallbackDistance = fallbackRadius / (fitFraction * Math.tan(halfFovY)); + // 采样点按「上下向投影绝对值 / 左右向投影绝对值 / 深度」三个浮点数存下来,比例变化时直接在 + // 这份样本上重算,不必再走一遍 updateMatrixWorld + 逐顶点读取(单个网格上限 20 万点)。 + let sampleCount = 0; + let samples = new Float32Array(3 * 4096); + // 非法顶点照旧要作废整次取景:旧实现里 NaN 会把累加出的最大值污染成 NaN,这里保持同一口径。 + let hasInvalidSample = false; + + function resolveForAspect(value: number): number { + if (hasInvalidSample) { + return fallbackDistance; + } + const limitX = limitY * normalizePositive(value, FALLBACK_ASPECT); + let required = 0; + for (let index = 0; index < sampleCount; index += 1) { + const base = index * 3; + const upAbs = samples[base] ?? 0; + const rightAbs = samples[base + 1] ?? 0; + const depth = samples[base + 2] ?? 0; + const byUp = upAbs / limitY - depth; + if (byUp > required) { + required = byUp; + } + const byRight = rightAbs / limitX - depth; + if (byRight > required) { + required = byRight; + } + } + if (!Number.isFinite(required) || required <= 0) { + return fallbackDistance; + } + return Math.max(required * FIT_SAFETY, fallbackRadius); + } + return { add(x: number, y: number, z: number) { + if (sampleCount * 3 + 3 > samples.length) { + const grown = new Float32Array(samples.length * 2); + grown.set(samples); + samples = grown; + } const corner = subtract({ x, y, z }, center); const depth = dot(corner, forward); - required = Math.max( - required, - Math.abs(dot(corner, up)) / limitY - depth, - Math.abs(dot(corner, right)) / limitX - depth, - ); - }, - resolve() { - if (!Number.isFinite(required) || required <= 0) { - return fallbackDistance; + const upAbs = Math.abs(dot(corner, up)); + const rightAbs = Math.abs(dot(corner, right)); + if ( + !Number.isFinite(upAbs) || + !Number.isFinite(rightAbs) || + !Number.isFinite(depth) + ) { + hasInvalidSample = true; } - return Math.max(required * FIT_SAFETY, fallbackRadius); + const base = sampleCount * 3; + samples[base] = upAbs; + samples[base + 1] = rightAbs; + samples[base + 2] = depth; + sampleCount += 1; + }, + resolve(value?: number) { + return resolveForAspect(value ?? aspect); }, }; } diff --git a/packages/model3d-viewer/src/scene.ts b/packages/model3d-viewer/src/scene.ts index 5d9ad026a..575f17d46 100644 --- a/packages/model3d-viewer/src/scene.ts +++ b/packages/model3d-viewer/src/scene.ts @@ -285,27 +285,30 @@ export async function createModel3dViewerScene( const bounds = new THREE.Box3().setFromObject(model3d); const viewport = measureViewport(); + const fitBox = { + min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }, + max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z }, + }; + const fitSolver = createModel3dViewerFitSolver({ + center: computeModel3dViewerBoxCenter(fitBox), + aspect: viewport.aspect, + fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, + fallbackRadius: computeModel3dViewerBoxRadius(fitBox), + }); + // 顶点采样只做一次:投影结果与容器宽高比无关,容器尺寸变化只是在同一份样本上重取最大值, + // 不再重复 updateMatrixWorld + 逐顶点读取(拖拽面板时 ResizeObserver 会连续回调)。 + feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z)); + /** * 按给定宽高比重算取景。容器在隐藏面板里挂载时首帧量不到尺寸,只能先按方形兜底, * 等容器真正有尺寸后必须用真实比例重算,否则模型会按方形取景、被裁掉或显得过小。 */ function computeFraming(aspect: number) { - const fitBox = { - min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z }, - max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z }, - }; - const fitSolver = createModel3dViewerFitSolver({ - center: computeModel3dViewerBoxCenter(fitBox), - aspect, - fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, - fallbackRadius: computeModel3dViewerBoxRadius(fitBox), - }); - feedModel3dViewerFitSolver(model3d, (x, y, z) => fitSolver.add(x, y, z)); return computeModel3dViewerFraming({ box: fitBox, aspect, fovDegrees: MODEL3D_VIEWER_CAMERA_FOV_DEGREES, - distance: fitSolver.resolve(), + distance: fitSolver.resolve(aspect), }); } @@ -406,7 +409,7 @@ export async function createModel3dViewerScene( } // 首帧按创建时的比例(容器量不到尺寸时是方形兜底)套一次取景;真实比例由下面的 - // ResizeObserver 首轮回调重算,避免在这里重复一次顶点采样。 + // ResizeObserver 首轮回调重算,重算只走缓存样本,不再重复顶点采样。 applyFraming(framing, false); applyViewport();