Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
e16c357f0a feat(load3d): fill letterbox bars with the dimmed surrounding scene 2026-07-15 20:10:22 -04:00
10 changed files with 504 additions and 27 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -833,6 +833,44 @@ describe('useLoad3d', () => {
expect(composable.sceneConfig.value.backgroundImage).toBe('')
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('')
})
it('should reset a leftover panorama mode to tiled when uploading a new image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded-image.jpg')
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('tiled')
await nextTick()
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenLastCalledWith(
'tiled'
)
})
it('should not clear the background or touch render mode when the upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue(undefined)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
composable.sceneConfig.value.backgroundImage = 'existing.jpg'
composable.sceneConfig.value.backgroundRenderMode = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await composable.handleBackgroundImageUpdate(file)
expect(composable.sceneConfig.value.backgroundImage).toBe('existing.jpg')
expect(composable.sceneConfig.value.backgroundRenderMode).toBe('panorama')
})
})
describe('model export', () => {

View File

@@ -770,6 +770,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
: '3d'
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
if (!uploadedPath) return
sceneConfig.value.backgroundRenderMode = 'tiled'
sceneConfig.value.backgroundImage = uploadedPath
await load3d?.setBackgroundImage(uploadedPath)
}

View File

@@ -560,6 +560,44 @@ describe('useLoad3dViewer', () => {
expect(viewer.hasBackgroundImage.value).toBe(false)
})
it('should reset render mode to tiled when uploading a new image', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(
'uploaded-image.jpg'
)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundRenderMode.value = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(viewer.backgroundRenderMode.value).toBe('tiled')
})
it('should not clear the background or touch render mode when the upload fails', async () => {
vi.mocked(Load3dUtils.uploadFile).mockResolvedValueOnce(undefined)
const viewer = useLoad3dViewer(mockNode)
const containerRef = document.createElement('div')
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
viewer.backgroundImage.value = 'existing.jpg'
viewer.backgroundRenderMode.value = 'panorama'
await nextTick()
const file = new File([''], 'test.jpg', { type: 'image/jpeg' })
await viewer.handleBackgroundImageUpdate(file)
expect(viewer.backgroundImage.value).toBe('existing.jpg')
expect(viewer.backgroundRenderMode.value).toBe('panorama')
})
it('should handle upload errors', async () => {
vi.mocked(Load3dUtils.uploadFile).mockRejectedValueOnce(
new Error('Upload failed')

View File

@@ -752,6 +752,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
)
if (uploadPath) {
backgroundRenderMode.value = 'tiled'
backgroundImage.value = uploadPath
hasBackgroundImage.value = true
}

View File

@@ -353,7 +353,10 @@ describe('Load3d', () => {
expect(sceneResize).toHaveBeenCalledWith(800, 400)
})
it('renderMainScene applies the letterboxed viewport and feeds aspect to the camera', () => {
function makeRenderMainSceneContext(
backgroundType: 'color' | 'image' = 'color',
activeCamera: THREE.Camera = ctx.cameraManager.activeCamera
) {
const setViewport = vi.fn()
const setScissor = vi.fn()
const setScissorTest = vi.fn()
@@ -362,6 +365,10 @@ describe('Load3d', () => {
const render = vi.fn()
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const getCurrentBackgroundInfo = vi.fn(() => ({
type: backgroundType,
value: ''
}))
const scene = {} as THREE.Scene
@@ -383,19 +390,133 @@ describe('Load3d', () => {
targetHeight: 200,
targetAspectRatio: 2,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, updateAspectRatio },
sceneManager: { ...ctx.sceneManager, renderBackground, scene }
cameraManager: {
...ctx.cameraManager,
updateAspectRatio,
activeCamera
},
sceneManager: {
...ctx.sceneManager,
renderBackground,
getCurrentBackgroundInfo,
scene
}
})
return {
setViewport,
setScissor,
setScissorTest,
clear,
render,
updateAspectRatio,
renderBackground,
scene
}
}
it('renderMainScene renders the full canvas with an extrapolated view offset so the letterboxed rect is unchanged', () => {
const r = makeRenderMainSceneContext()
const camera = ctx.cameraManager.activeCamera as THREE.PerspectiveCamera
let viewAtRender: THREE.PerspectiveCamera['view'] = null
r.render.mockImplementationOnce(() => {
viewAtRender = camera.view ? { ...camera.view } : null
})
ctx.load3d.renderMainScene()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(updateAspectRatio).toHaveBeenCalledWith(2)
expect(setScissorTest).toHaveBeenCalledWith(true)
expect(render).toHaveBeenCalledWith(scene, ctx.cameraManager.activeCamera)
// Container 800x600, target aspect 2:1 → letterboxed rect 800x400 at y=100
expect(r.setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setScissor).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setScissorTest).toHaveBeenCalledWith(true)
expect(r.clear).toHaveBeenCalledOnce()
expect(r.updateAspectRatio).toHaveBeenCalledWith(2)
expect(r.render).toHaveBeenNthCalledWith(1, r.scene, camera)
expect(viewAtRender).not.toBeNull()
expect(viewAtRender!.enabled).toBe(true)
expect(viewAtRender!.fullWidth).toBe(800)
expect(viewAtRender!.fullHeight).toBe(400)
expect(viewAtRender!.offsetX).toBeCloseTo(0)
expect(viewAtRender!.offsetY).toBe(-100)
expect(viewAtRender!.width).toBe(800)
expect(viewAtRender!.height).toBe(600)
expect(camera.view?.enabled ?? false).toBe(false)
})
it('renderMainScene dims the letterbox bars after rendering the scene', () => {
const r = makeRenderMainSceneContext()
ctx.load3d.renderMainScene()
expect(r.render).toHaveBeenCalledTimes(3)
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
expect(r.setViewport).toHaveBeenNthCalledWith(3, 0, 500, 800, 100)
expect(r.setScissor).toHaveBeenNthCalledWith(3, 0, 500, 800, 100)
})
it('renderMainScene keeps a color background covering the whole canvas', () => {
const r = makeRenderMainSceneContext('color')
ctx.load3d.renderMainScene()
expect(r.renderBackground).toHaveBeenCalledWith()
// No viewport narrowing before the bars are dimmed.
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 0, 800, 100)
})
it('renderMainScene confines an image background to the letterboxed rect', () => {
const r = makeRenderMainSceneContext('image')
ctx.load3d.renderMainScene()
// Viewport/scissor narrow to the letterbox rect for the background
// pass, then restore to the full canvas for the scene pass.
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setViewport).toHaveBeenNthCalledWith(3, 0, 0, 800, 600)
expect(r.setScissor).toHaveBeenNthCalledWith(3, 0, 0, 800, 600)
const backgroundOrder = r.renderBackground.mock.invocationCallOrder[0]
expect(backgroundOrder).toBeGreaterThan(
r.setViewport.mock.invocationCallOrder[1]
)
expect(backgroundOrder).toBeLessThan(
r.setViewport.mock.invocationCallOrder[2]
)
})
it('renderMainScene applies the view offset to orthographic cameras too', () => {
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1)
const r = makeRenderMainSceneContext('color', camera)
let viewEnabledAtRender = false
r.render.mockImplementationOnce(() => {
viewEnabledAtRender = camera.view?.enabled ?? false
})
ctx.load3d.renderMainScene()
expect(r.render).toHaveBeenNthCalledWith(1, r.scene, camera)
expect(viewEnabledAtRender).toBe(true)
expect(camera.view?.enabled ?? false).toBe(false)
})
it('renderMainScene falls back to the letterboxed viewport for cameras without view-offset support', () => {
const camera = new THREE.Camera()
const r = makeRenderMainSceneContext('color', camera)
ctx.load3d.renderMainScene()
expect(r.setViewport).toHaveBeenNthCalledWith(1, 0, 0, 800, 600)
expect(r.setViewport).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.setScissor).toHaveBeenNthCalledWith(2, 0, 100, 800, 400)
expect(r.render).toHaveBeenCalledTimes(1)
expect(r.render).toHaveBeenCalledWith(r.scene, camera)
expect(r.renderBackground).toHaveBeenCalledWith()
})
it('setBackgroundImage updates background size with letterbox dimensions when a texture is loaded', async () => {

View File

@@ -13,6 +13,7 @@ type CameraStub = {
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
@@ -48,6 +49,7 @@ function makeViewportInstance() {
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
dispose: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
@@ -447,6 +449,82 @@ describe('Viewport3d', () => {
})
})
describe('letterbox dimmer lifecycle', () => {
type DimmerInternals = {
dimLetterboxBars(
bars: { x: number; y: number; width: number; height: number }[]
): void
disposeManagers(): void
letterboxDimmer: {
scene: THREE.Scene
camera: THREE.OrthographicCamera
geometry: THREE.PlaneGeometry
material: THREE.MeshBasicMaterial
} | null
}
it('does not create the dim overlay when there are no letterbox bars', () => {
const render = vi.fn()
Object.assign(ctx.viewport, {
view: {
renderer: { setViewport: vi.fn(), setScissor: vi.fn(), render }
}
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([])
expect(render).not.toHaveBeenCalled()
expect(internals.letterboxDimmer ?? null).toBeNull()
})
it('renders each bar with a lazily created dim overlay', () => {
const setViewport = vi.fn()
const setScissor = vi.fn()
const render = vi.fn()
Object.assign(ctx.viewport, {
view: { renderer: { setViewport, setScissor, render } }
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([
{ x: 0, y: 0, width: 100, height: 20 },
{ x: 0, y: 80, width: 100, height: 20 }
])
const dimmer = internals.letterboxDimmer
expect(dimmer).not.toBeNull()
expect(setViewport).toHaveBeenNthCalledWith(1, 0, 0, 100, 20)
expect(setViewport).toHaveBeenNthCalledWith(2, 0, 80, 100, 20)
expect(render).toHaveBeenCalledTimes(2)
expect(render).toHaveBeenCalledWith(dimmer!.scene, dimmer!.camera)
})
it('disposeManagers disposes the dim overlay resources', () => {
Object.assign(ctx.viewport, {
view: {
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
render: vi.fn()
}
}
})
const internals = ctx.viewport as unknown as DimmerInternals
internals.dimLetterboxBars([{ x: 0, y: 0, width: 100, height: 20 }])
const dimmer = internals.letterboxDimmer!
const geometryDispose = vi.spyOn(dimmer.geometry, 'dispose')
const materialDispose = vi.spyOn(dimmer.material, 'dispose')
internals.disposeManagers()
expect(geometryDispose).toHaveBeenCalledOnce()
expect(materialDispose).toHaveBeenCalledOnce()
expect(internals.letterboxDimmer).toBeNull()
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -18,13 +18,52 @@ import type {
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import type { LetterboxNdc } from './load3dViewport'
import type {
LetterboxedViewport,
LetterboxNdc,
ViewportRect
} from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxBars,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
const LETTERBOX_CLEAR_COLOR = 0x0a0a0a
const LETTERBOX_DIM_OPACITY = 0.5
type LetterboxDimmer = {
scene: THREE.Scene
camera: THREE.OrthographicCamera
geometry: THREE.PlaneGeometry
material: THREE.MeshBasicMaterial
}
function supportsViewOffset(
camera: THREE.Camera
): camera is THREE.PerspectiveCamera | THREE.OrthographicCamera {
return (
camera instanceof THREE.PerspectiveCamera ||
camera instanceof THREE.OrthographicCamera
)
}
function createLetterboxDimmer(): LetterboxDimmer {
const geometry = new THREE.PlaneGeometry(2, 2)
const material = new THREE.MeshBasicMaterial({
color: 0x000000,
transparent: true,
opacity: LETTERBOX_DIM_OPACITY,
depthTest: false,
depthWrite: false
})
const scene = new THREE.Scene()
scene.add(new THREE.Mesh(geometry, material))
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
return { scene, camera, geometry, material }
}
const VIEW_HELPER_SIZE = 128
export type Viewport3dDeps = {
@@ -67,6 +106,7 @@ export class Viewport3d {
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
private viewPixelScale = 1
private letterboxDimmer: LetterboxDimmer | null = null
constructor(
container: HTMLElement,
@@ -257,29 +297,96 @@ export class Viewport3d {
this.renderer.setScissor(0, 0, viewWidth, viewHeight)
this.renderer.setScissorTest(true)
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: viewWidth, height: viewHeight },
this.targetAspectRatio
)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
if (!this.shouldMaintainAspectRatio()) {
this.renderer.setClearColor(
this.view.state.clearColor,
this.view.state.clearAlpha
)
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
return
}
const container = { width: viewWidth, height: viewHeight }
const viewport = computeLetterboxedViewport(
container,
this.targetAspectRatio
)
this.renderer.setClearColor(LETTERBOX_CLEAR_COLOR)
this.renderer.clear()
this.cameraManager.updateAspectRatio(viewport.width / viewport.height)
const camera = this.getRenderCamera()
if (!supportsViewOffset(camera)) {
this.renderer.setViewport(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.renderer.setScissor(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, camera)
return
}
camera.setViewOffset(
viewport.width,
viewport.height,
-viewport.offsetX,
-viewport.offsetY,
viewWidth,
viewHeight
)
this.renderLetterboxedBackground(viewport)
this.renderer.render(this.sceneManager.scene, camera)
camera.clearViewOffset()
this.dimLetterboxBars(computeLetterboxBars(container, viewport))
}
private renderLetterboxedBackground(viewport: LetterboxedViewport): void {
if (this.sceneManager.getCurrentBackgroundInfo().type !== 'image') {
this.sceneManager.renderBackground()
return
}
this.renderer.setViewport(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.renderer.setScissor(
viewport.offsetX,
viewport.offsetY,
viewport.width,
viewport.height
)
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
this.renderer.setViewport(0, 0, this.view.width, this.view.height)
this.renderer.setScissor(0, 0, this.view.width, this.view.height)
}
private dimLetterboxBars(bars: ViewportRect[]): void {
if (bars.length === 0) return
const dimmer = (this.letterboxDimmer ??= createLetterboxDimmer())
for (const bar of bars) {
this.renderer.setViewport(bar.x, bar.y, bar.width, bar.height)
this.renderer.setScissor(bar.x, bar.y, bar.width, bar.height)
this.renderer.render(dimmer.scene, dimmer.camera)
}
}
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
@@ -435,6 +542,11 @@ export class Viewport3d {
this.overlay.dispose()
this.overlay = null
}
if (this.letterboxDimmer) {
this.letterboxDimmer.geometry.dispose()
this.letterboxDimmer.material.dispose()
this.letterboxDimmer = null
}
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
clientPointToLetterboxNdc,
computeLetterboxBars,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
@@ -82,6 +83,53 @@ describe('computeLetterboxedViewport', () => {
})
})
describe('computeLetterboxBars', () => {
it('returns left and right bars for a pillarboxed viewport', () => {
const container = { width: 800, height: 400 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1)
)
expect(bars).toEqual([
{ x: 0, y: 0, width: 200, height: 400 },
{ x: 600, y: 0, width: 200, height: 400 }
])
})
it('returns bottom and top bars for a letterboxed viewport', () => {
const container = { width: 400, height: 800 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1)
)
expect(bars).toEqual([
{ x: 0, y: 0, width: 400, height: 200 },
{ x: 0, y: 600, width: 400, height: 200 }
])
})
it('returns no bars when the viewport fills the container', () => {
const container = { width: 1024, height: 768 }
const bars = computeLetterboxBars(
container,
computeLetterboxedViewport(container, 1024 / 768)
)
expect(bars).toEqual([])
})
it('ignores sub-pixel offsets', () => {
const bars = computeLetterboxBars(
{ width: 800, height: 400.5 },
{ offsetX: 0, offsetY: 0.25, width: 800, height: 400 }
)
expect(bars).toEqual([])
})
})
describe('isLoad3dActive', () => {
const idle: Load3dActivityFlags = {
mouseOnNode: false,

View File

@@ -2,13 +2,20 @@ import { denormalize, normalize } from '@/utils/mathUtil'
type Size = { width: number; height: number }
type LetterboxedViewport = {
export type LetterboxedViewport = {
offsetX: number
offsetY: number
width: number
height: number
}
export type ViewportRect = {
x: number
y: number
width: number
height: number
}
export function computeLetterboxedViewport(
container: Size,
targetAspectRatio: number
@@ -69,6 +76,37 @@ export function clientPointToLetterboxNdc(
)
}
export function computeLetterboxBars(
container: Size,
viewport: LetterboxedViewport
): ViewportRect[] {
if (viewport.offsetX >= 1) {
return [
{ x: 0, y: 0, width: viewport.offsetX, height: container.height },
{
x: viewport.offsetX + viewport.width,
y: 0,
width: container.width - viewport.offsetX - viewport.width,
height: container.height
}
]
}
if (viewport.offsetY >= 1) {
return [
{ x: 0, y: 0, width: container.width, height: viewport.offsetY },
{
x: 0,
y: viewport.offsetY + viewport.height,
width: container.width,
height: container.height - viewport.offsetY - viewport.height
}
]
}
return []
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean