mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
4 Commits
model_down
...
fix/ppform
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29c3488c14 | ||
|
|
4972e4d42f | ||
|
|
8d8ae7dfc0 | ||
|
|
38c034e1d6 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -255,9 +255,10 @@
|
||||
Shifting visually with `top` corrects the appearance without changing the
|
||||
inline-block's bounding box, so button/badge sizes are unaffected. */
|
||||
@utility ppformula-text-center {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
/* display: inline-block; */
|
||||
/* position: relative; */
|
||||
/* top: 0.1em; */
|
||||
--ppformula-text-center: 1;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -276,6 +276,7 @@ export const TestIds = {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3d: {
|
||||
categoryMenu: 'load3d-category-menu',
|
||||
recordingDuration: 'load3d-recording-duration'
|
||||
},
|
||||
load3dViewer: {
|
||||
|
||||
@@ -11,39 +11,47 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get menuButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /show menu/i })
|
||||
return this.node.getByTestId(TestIds.load3d.categoryMenu)
|
||||
}
|
||||
|
||||
private get menuPanel(): Locator {
|
||||
return this.node.page().getByRole('dialog')
|
||||
}
|
||||
|
||||
get recordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /start recording/i })
|
||||
return this.node.getByRole('button', { name: 'Record', exact: true })
|
||||
}
|
||||
|
||||
get stopRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /stop recording/i })
|
||||
}
|
||||
|
||||
get exportRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /export recording/i })
|
||||
}
|
||||
|
||||
get clearRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /clear recording/i })
|
||||
}
|
||||
|
||||
get recordingDuration(): Locator {
|
||||
get recordingMenuButton(): Locator {
|
||||
return this.node.getByTestId(TestIds.load3d.recordingDuration)
|
||||
}
|
||||
|
||||
get downloadRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
|
||||
}
|
||||
|
||||
get startNewRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
|
||||
}
|
||||
|
||||
get deleteRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
|
||||
}
|
||||
|
||||
get gridToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /show grid/i })
|
||||
}
|
||||
|
||||
get uploadBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /upload background image/i })
|
||||
return this.node.getByRole('button', { name: 'BG Image' })
|
||||
}
|
||||
|
||||
get removeBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /remove background image/i })
|
||||
return this.node.getByRole('button', { name: 'Remove BG' })
|
||||
}
|
||||
|
||||
get panoramaModeButton(): Locator {
|
||||
@@ -54,6 +62,10 @@ export class Load3DHelper {
|
||||
return this.node.locator('input[type="color"]')
|
||||
}
|
||||
|
||||
get exportButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Export', exact: true })
|
||||
}
|
||||
|
||||
get openViewerButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /open in 3d viewer/i })
|
||||
}
|
||||
@@ -63,11 +75,15 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
getMenuCategory(name: string): Locator {
|
||||
return this.node.getByText(name, { exact: true })
|
||||
return this.menuPanel.getByRole('button', { name, exact: true })
|
||||
}
|
||||
|
||||
get gizmoToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Gizmo' })
|
||||
// The category chip is also named "Gizmo" once that category is active;
|
||||
// only the toggle carries aria-pressed.
|
||||
return this.node
|
||||
.getByRole('button', { name: 'Gizmo' })
|
||||
.and(this.node.locator('[aria-pressed]'))
|
||||
}
|
||||
|
||||
get gizmoTranslateButton(): Locator {
|
||||
@@ -83,13 +99,17 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get gizmoResetButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Reset Transform' })
|
||||
return this.node.getByRole('button', { name: 'Reset', exact: true })
|
||||
}
|
||||
|
||||
async openMenu(): Promise<void> {
|
||||
await this.menuButton.click()
|
||||
}
|
||||
|
||||
async openRecordingMenu(): Promise<void> {
|
||||
await this.recordingMenuButton.click()
|
||||
}
|
||||
|
||||
async openGizmoCategory(): Promise<void> {
|
||||
await this.openMenu()
|
||||
await this.getMenuCategory('Gizmo').click()
|
||||
|
||||
@@ -47,10 +47,12 @@ test.describe('Load3D', () => {
|
||||
await load3d.openMenu()
|
||||
|
||||
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Model')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Light')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Export')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
|
||||
await expect(load3d.exportButton).toBeVisible()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-controls-menu-open.png',
|
||||
@@ -253,7 +255,7 @@ test.describe('Load3D', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('Recording controls show stop/export/clear buttons after a recording', async ({
|
||||
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
@@ -265,20 +267,25 @@ test.describe('Load3D', () => {
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
|
||||
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
|
||||
// Record for 1s wall-clock so the duration display settles on 00:01.
|
||||
await comfyPage.delay(1000)
|
||||
await load3d.stopRecordingButton.click()
|
||||
await expect(load3d.recordingButton).toBeVisible()
|
||||
await expect(load3d.exportRecordingButton).toBeVisible()
|
||||
await expect(load3d.clearRecordingButton).toBeVisible()
|
||||
await expect(load3d.recordingDuration).toHaveText('00:01')
|
||||
await expect(load3d.recordingMenuButton).toBeVisible()
|
||||
await expect(load3d.recordingMenuButton).toHaveText('00:01')
|
||||
})
|
||||
|
||||
await test.step('Clear recording removes export and clear controls', async () => {
|
||||
await load3d.clearRecordingButton.click()
|
||||
await expect(load3d.exportRecordingButton).toHaveCount(0)
|
||||
await expect(load3d.clearRecordingButton).toHaveCount(0)
|
||||
await test.step('Chip menu offers download, re-record and delete actions', async () => {
|
||||
await load3d.openRecordingMenu()
|
||||
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
|
||||
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
|
||||
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Deleting the recording restores the record button', async () => {
|
||||
await load3d.deleteRecordingMenuItem.click()
|
||||
await expect(load3d.recordingMenuButton).toHaveCount(0)
|
||||
await expect(load3d.recordingButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 90 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 40 KiB |
@@ -128,9 +128,9 @@ function renderLoad3D(options: RenderOptions = {}) {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
RecordingControls: {
|
||||
name: 'RecordingControls',
|
||||
template: '<div data-testid="recording-controls" />'
|
||||
RecordMenuControl: {
|
||||
name: 'RecordMenuControl',
|
||||
template: '<div data-testid="record-menu-control" />'
|
||||
},
|
||||
ViewerControls: {
|
||||
name: 'ViewerControls',
|
||||
@@ -232,14 +232,16 @@ describe('Load3D', () => {
|
||||
})
|
||||
|
||||
describe('recording controls', () => {
|
||||
it('renders RecordingControls in regular (non-preview) mode', () => {
|
||||
it('renders the record control in regular (non-preview) mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
|
||||
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('record-menu-control')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides RecordingControls in preview mode', () => {
|
||||
it('hides the record control in preview mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
|
||||
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('record-menu-control')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -15,25 +15,39 @@
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 size-full">
|
||||
<Load3DControls
|
||||
<Load3DMenuBar
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
:can-use-gizmo="canUseGizmo"
|
||||
:can-use-lighting="canUseLighting"
|
||||
:can-export="canExport"
|
||||
:can-use-hdri="canUseHdri"
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:can-fit-to-viewer="canFitToViewer"
|
||||
:can-center-camera-on-model="canCenterCameraOnModel"
|
||||
:node="node as LGraphNode"
|
||||
:enable-viewer="enable3DViewer"
|
||||
:can-use-recording="canUseRecording && !isPreview"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
:source-format="sourceFormat"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@fit-to-viewer="handleFitToViewer"
|
||||
@center-camera="handleCenterCameraOnModel"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -46,59 +60,6 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="canFitToViewer || canCenterCameraOnModel"
|
||||
class="flex flex-col rounded-lg bg-backdrop/30"
|
||||
>
|
||||
<Button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.fitToViewer')"
|
||||
@click="handleFitToViewer"
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.centerCameraOnModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.centerCameraOnModel')"
|
||||
@click="handleCenterCameraOnModel"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ViewerControls
|
||||
v-if="enable3DViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
|
||||
<RecordingControls
|
||||
v-if="canUseRecording && !isPreview"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,12 +67,9 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
||||
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
|
||||
import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -192,11 +150,11 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
242
src/components/load3d/Load3DMenuBar.test.ts
Normal file
242
src/components/load3d/Load3DMenuBar.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeSceneConfig(): SceneConfig {
|
||||
return {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
}
|
||||
}
|
||||
|
||||
function makeModelConfig(): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCameraConfig(): CameraConfig {
|
||||
return { cameraType: 'perspective', fov: 75 }
|
||||
}
|
||||
|
||||
function makeLightConfig(): LightConfig {
|
||||
return {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RenderProps = Partial<ComponentProps<typeof Load3DMenuBar>>
|
||||
|
||||
function renderMenuBar(overrides: RenderProps = {}) {
|
||||
const result = render(Load3DMenuBar, {
|
||||
props: {
|
||||
sceneConfig: makeSceneConfig(),
|
||||
modelConfig: makeModelConfig(),
|
||||
cameraConfig: makeCameraConfig(),
|
||||
lightConfig: makeLightConfig(),
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function selectCategory(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
label: string
|
||||
) {
|
||||
await openCategoryMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
|
||||
async function openCategoryMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: /Scene/ }))
|
||||
}
|
||||
|
||||
describe('Load3DMenuBar', () => {
|
||||
it('shows scene controls by default', () => {
|
||||
renderMenuBar()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show grid' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles showGrid on the bound config when the grid button is clicked', async () => {
|
||||
const sceneConfig = makeSceneConfig()
|
||||
const { user } = renderMenuBar({ sceneConfig })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(sceneConfig.showGrid).toBe(false)
|
||||
})
|
||||
|
||||
it('emits fitToViewer when the fit button is clicked', async () => {
|
||||
const onFitToViewer = vi.fn()
|
||||
const { user } = renderMenuBar({ onFitToViewer })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fit to Viewer' }))
|
||||
|
||||
expect(onFitToViewer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('emits centerCamera when the center button is clicked', async () => {
|
||||
const onCenterCamera = vi.fn()
|
||||
const { user } = renderMenuBar({ onCenterCamera })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Center Camera on Model' })
|
||||
)
|
||||
|
||||
expect(onCenterCamera).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the center button when canCenterCameraOnModel is false', () => {
|
||||
renderMenuBar({ canCenterCameraOnModel: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Center Camera on Model' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the gizmo and reveals the mode controls inline', async () => {
|
||||
const onToggleGizmo = vi.fn()
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const { user } = renderMenuBar({ onToggleGizmo, onSetGizmoMode })
|
||||
|
||||
await selectCategory(user, 'Gizmo')
|
||||
// The chip and the enable toggle share the 'Gizmo' name; click the toggle.
|
||||
const gizmoButtons = screen.getAllByRole('button', { name: 'Gizmo' })
|
||||
await user.click(gizmoButtons[gizmoButtons.length - 1])
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }))
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
})
|
||||
|
||||
it('shows the hdri upload inline without an extra popover', async () => {
|
||||
const { user } = renderMenuBar()
|
||||
|
||||
await selectCategory(user, 'HDRI')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards removeHdri as updateHdriFile(null) when a file is loaded', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const lightConfig = makeLightConfig()
|
||||
lightConfig.hdri = {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
const { user } = renderMenuBar({ lightConfig, onUpdateHdriFile })
|
||||
|
||||
await selectCategory(user, 'HDRI')
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('emits startRecording when the record button is clicked', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderMenuBar({ onStartRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Record' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards exportRecording from the recording menu once a recording exists', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const { user } = renderMenuBar({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('omits the gizmo category when canUseGizmo is false', async () => {
|
||||
const { user } = renderMenuBar({ canUseGizmo: false })
|
||||
await openCategoryMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Gizmo' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the camera category and shows its controls', async () => {
|
||||
const { user } = renderMenuBar()
|
||||
await openCategoryMenu(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Camera' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Perspective' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show grid' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the light category when canUseLighting is false', async () => {
|
||||
const { user } = renderMenuBar({ canUseLighting: false })
|
||||
await openCategoryMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides scene controls when sceneConfig is undefined', () => {
|
||||
renderMenuBar({ sceneConfig: undefined })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show grid' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
337
src/components/load3d/Load3DMenuBar.vue
Normal file
337
src/components/load3d/Load3DMenuBar.vue
Normal file
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col">
|
||||
<div
|
||||
ref="topBarRef"
|
||||
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<Popover v-model:open="catMenuOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
data-testid="load3d-category-menu"
|
||||
>
|
||||
{{ activeLabel }}
|
||||
<i class="icon-[lucide--chevron-down] size-4 opacity-70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="c in categoryDefs"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
rowClass,
|
||||
activeCategory === c.key && 'bg-button-active-surface'
|
||||
)
|
||||
"
|
||||
@click="selectCategory(c.key)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
|
||||
|
||||
<SceneMenuGroup
|
||||
v-if="activeCategory === 'scene' && sceneConfig"
|
||||
v-model:config="sceneConfig"
|
||||
v-model:fov="cameraFov"
|
||||
:compact
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:hdri-active="hdriActive"
|
||||
@update-background-image="emit('updateBackgroundImage', $event)"
|
||||
/>
|
||||
<ModelMenuGroup
|
||||
v-else-if="activeCategory === 'model' && modelConfig"
|
||||
v-model:config="modelConfig"
|
||||
:compact
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
<CameraMenuGroup
|
||||
v-else-if="activeCategory === 'camera' && cameraConfig"
|
||||
v-model:config="cameraConfig"
|
||||
:compact
|
||||
/>
|
||||
<LightMenuGroup
|
||||
v-else-if="activeCategory === 'light' && lightConfig && modelConfig"
|
||||
v-model:config="lightConfig"
|
||||
:compact
|
||||
:is-original-material="isOriginalMaterial"
|
||||
/>
|
||||
<HdriMenuGroup
|
||||
v-else-if="activeCategory === 'hdri' && lightConfig"
|
||||
v-model:config="lightConfig"
|
||||
:compact
|
||||
:scene-has-image="sceneHasImage"
|
||||
@update-hdri-file="emit('updateHdriFile', $event)"
|
||||
/>
|
||||
<GizmoMenuGroup
|
||||
v-else-if="activeCategory === 'gizmo' && modelConfig"
|
||||
v-model:config="modelConfig"
|
||||
:compact
|
||||
@toggle-gizmo="emit('toggleGizmo', $event)"
|
||||
@set-gizmo-mode="emit('setGizmoMode', $event)"
|
||||
@reset-gizmo-transform="emit('resetGizmoTransform')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn('flex-1', isRecording && 'border-2 border-node-component-executing')
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto flex h-10 items-center justify-between gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<RecordMenuControl
|
||||
v-if="canUseRecording"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
:compact
|
||||
@start-recording="emit('startRecording')"
|
||||
@stop-recording="emit('stopRecording')"
|
||||
@export-recording="emit('exportRecording')"
|
||||
@clear-recording="emit('clearRecording')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<ViewerControls
|
||||
v-if="enableViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
<button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.top="tip(t('load3d.fitToViewer'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.fitToViewer')"
|
||||
@click="emit('fitToViewer')"
|
||||
>
|
||||
<i class="icon-[lucide--scan] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.top="tip(t('load3d.centerCameraOnModel'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.centerCameraOnModel')"
|
||||
@click="emit('centerCamera')"
|
||||
>
|
||||
<i class="icon-[lucide--crosshair] size-4" />
|
||||
</button>
|
||||
<Popover v-if="canExport" v-model:open="exportOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.export'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.export')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
type="button"
|
||||
:class="rowClass"
|
||||
@click="onExport(format.value)"
|
||||
>
|
||||
{{ format.label }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
|
||||
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
|
||||
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
|
||||
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
|
||||
import {
|
||||
chipClass,
|
||||
iconBtnClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
|
||||
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
|
||||
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
canUseLighting = true,
|
||||
canUseHdri = true,
|
||||
canUseGizmo = true,
|
||||
canExport = true,
|
||||
canUseBackgroundImage = true,
|
||||
canFitToViewer = true,
|
||||
canCenterCameraOnModel = true,
|
||||
canUseRecording = true,
|
||||
enableViewer = false,
|
||||
node = null,
|
||||
materialModes = ['original', 'clay', 'normal', 'wireframe'],
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
} = defineProps<{
|
||||
canUseLighting?: boolean
|
||||
canUseHdri?: boolean
|
||||
canUseGizmo?: boolean
|
||||
canExport?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
canFitToViewer?: boolean
|
||||
canCenterCameraOnModel?: boolean
|
||||
canUseRecording?: boolean
|
||||
enableViewer?: boolean
|
||||
node?: LGraphNode | null
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
const modelConfig = defineModel<ModelConfig>('modelConfig')
|
||||
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'fitToViewer'): void
|
||||
(e: 'centerCamera'): void
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const categoryDefs = computed(() =>
|
||||
[
|
||||
{ key: 'scene', label: t('load3d.scene'), show: !!sceneConfig.value },
|
||||
{
|
||||
key: 'model',
|
||||
label: t('load3d.model3d'),
|
||||
show: !!modelConfig.value
|
||||
},
|
||||
{ key: 'camera', label: t('load3d.camera'), show: !!cameraConfig.value },
|
||||
{
|
||||
key: 'light',
|
||||
label: t('load3d.light'),
|
||||
show: canUseLighting && !!lightConfig.value && !!modelConfig.value
|
||||
},
|
||||
{
|
||||
key: 'hdri',
|
||||
label: t('load3d.hdri.label'),
|
||||
show: canUseHdri && !!lightConfig.value
|
||||
},
|
||||
{
|
||||
key: 'gizmo',
|
||||
label: t('load3d.gizmo.label'),
|
||||
show: canUseGizmo && !!modelConfig.value
|
||||
}
|
||||
].filter((c) => c.show)
|
||||
)
|
||||
|
||||
const activeCategory = ref('scene')
|
||||
const activeLabel = computed(
|
||||
() =>
|
||||
categoryDefs.value.find((c) => c.key === activeCategory.value)?.label ?? ''
|
||||
)
|
||||
watch(categoryDefs, (defs) => {
|
||||
if (!defs.some((c) => c.key === activeCategory.value)) {
|
||||
activeCategory.value = defs[0]?.key ?? 'scene'
|
||||
}
|
||||
})
|
||||
|
||||
const catMenuOpen = ref(false)
|
||||
const exportOpen = ref(false)
|
||||
|
||||
const sceneHasImage = computed(
|
||||
() =>
|
||||
!!sceneConfig.value?.backgroundImage &&
|
||||
sceneConfig.value.backgroundImage !== ''
|
||||
)
|
||||
const hdriActive = computed(
|
||||
() =>
|
||||
!!lightConfig.value?.hdri?.hdriPath && !!lightConfig.value?.hdri?.enabled
|
||||
)
|
||||
const isOriginalMaterial = computed(
|
||||
() => modelConfig.value?.materialMode === 'original'
|
||||
)
|
||||
const cameraFov = computed({
|
||||
get: () => cameraConfig.value?.fov ?? 0,
|
||||
set: (value) => {
|
||||
if (cameraConfig.value) cameraConfig.value.fov = value
|
||||
}
|
||||
})
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
const topBarRef = ref<HTMLElement | null>(null)
|
||||
const { width: topW } = useElementSize(topBarRef)
|
||||
const compactWidthThreshold = 480
|
||||
const compact = computed(
|
||||
() => topW.value > 0 && topW.value < compactWidthThreshold
|
||||
)
|
||||
|
||||
function selectCategory(key: string) {
|
||||
activeCategory.value = key
|
||||
catMenuOpen.value = false
|
||||
}
|
||||
|
||||
function onExport(format: string) {
|
||||
emit('exportModel', format)
|
||||
exportOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,205 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
startRecording: 'Start recording',
|
||||
stopRecording: 'Stop recording',
|
||||
exportRecording: 'Export recording',
|
||||
clearRecording: 'Clear recording'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
hasRecording?: boolean
|
||||
isRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
|
||||
const isRecording = ref<boolean>(opts.isRecording ?? false)
|
||||
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
|
||||
|
||||
const utils = render(RecordingControls, {
|
||||
props: {
|
||||
hasRecording: hasRecording.value,
|
||||
'onUpdate:hasRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) hasRecording.value = v
|
||||
},
|
||||
isRecording: isRecording.value,
|
||||
'onUpdate:isRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) isRecording.value = v
|
||||
},
|
||||
recordingDuration: recordingDuration.value,
|
||||
'onUpdate:recordingDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) recordingDuration.value = v
|
||||
},
|
||||
onStartRecording: opts.onStartRecording,
|
||||
onStopRecording: opts.onStopRecording,
|
||||
onExportRecording: opts.onExportRecording,
|
||||
onClearRecording: opts.onClearRecording
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('RecordingControls', () => {
|
||||
it('shows the start-recording button initially', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Start recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Stop recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the stop-recording button while recording is in progress', () => {
|
||||
renderComponent({ isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Stop recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Start recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits startRecording when the button is clicked from a stopped state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: false,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Start recording' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
expect(onStopRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits stopRecording when the button is clicked from a recording state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: true,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
expect(onStartRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons when there is no recording', () => {
|
||||
renderComponent({ hasRecording: false, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the export and clear buttons once a recording exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Clear recording' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits exportRecording and clearRecording from their respective buttons', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export recording' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the formatted duration as MM:SS once a recording exists', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 75
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
|
||||
'01:15'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the duration display while a recording is in progress', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: true,
|
||||
recordingDuration: 30
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the duration display when recordingDuration is zero', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 0
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: isRecording
|
||||
? $t('load3d.stopRecording')
|
||||
: $t('load3d.startRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
isRecording && 'recording-button-blink text-red-500'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
isRecording ? $t('load3d.stopRecording') : $t('load3d.startRecording')
|
||||
"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
isRecording ? 'pi-circle-fill' : 'pi-video',
|
||||
'text-lg text-base-foreground'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.exportRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.exportRecording')"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.clearRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.clearRecording')"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i class="pi pi-trash text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-base-foreground"
|
||||
data-testid="load3d-recording-duration"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
function toggleRecording() {
|
||||
if (isRecording.value) {
|
||||
emit('stopRecording')
|
||||
} else {
|
||||
emit('startRecording')
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportRecording() {
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
function handleClearRecording() {
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recording-button-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
47
src/components/load3d/menubar/CameraMenuGroup.test.ts
Normal file
47
src/components/load3d/menubar/CameraMenuGroup.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<CameraConfig> = {}): CameraConfig {
|
||||
return { cameraType: 'perspective', fov: 75, ...overrides }
|
||||
}
|
||||
|
||||
function renderGroup(config = makeConfig()) {
|
||||
const result = render(CameraMenuGroup, {
|
||||
props: { config },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup(), config }
|
||||
}
|
||||
|
||||
describe('CameraMenuGroup', () => {
|
||||
it('switches the projection type', async () => {
|
||||
const { user, config } = renderGroup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Perspective' }))
|
||||
|
||||
expect(config.cameraType).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('offers the FOV control only for a perspective camera', () => {
|
||||
renderGroup(makeConfig({ cameraType: 'orthographic' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'FOV' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Orthographic' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
86
src/components/load3d/menubar/CameraMenuGroup.vue
Normal file
86
src/components/load3d/menubar/CameraMenuGroup.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.switchProjection'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.switchProjection') : undefined"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i class="icon-[lucide--camera] size-4" />
|
||||
<span v-if="!compact">{{ cameraTypeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<Popover v-if="isPerspective">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{ t('load3d.fov') }}</span>
|
||||
<Slider
|
||||
:model-value="[fov]"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="setFov"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<CameraConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cameraType = computed(() => config.value?.cameraType)
|
||||
const isPerspective = computed(() => cameraType.value === 'perspective')
|
||||
const cameraTypeLabel = computed(() =>
|
||||
cameraType.value ? t(`load3d.cameraType.${cameraType.value}`) : ''
|
||||
)
|
||||
const fov = computed(() => config.value?.fov ?? 0)
|
||||
|
||||
function switchCamera() {
|
||||
if (!config.value) return
|
||||
config.value.cameraType =
|
||||
config.value.cameraType === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
function setFov(value?: number[]) {
|
||||
if (config.value && value?.length) config.value.fov = value[0]
|
||||
}
|
||||
</script>
|
||||
72
src/components/load3d/menubar/GizmoMenuGroup.test.ts
Normal file
72
src/components/load3d/menubar/GizmoMenuGroup.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
|
||||
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(enabled: boolean): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config: ModelConfig
|
||||
onToggleGizmo?: (enabled: boolean) => void
|
||||
onSetGizmoMode?: (mode: string) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props) {
|
||||
const result = render(GizmoMenuGroup, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('GizmoMenuGroup', () => {
|
||||
it('enables the gizmo and reveals the mode controls', async () => {
|
||||
const config = makeConfig(false)
|
||||
const onToggleGizmo = vi.fn()
|
||||
const { user } = renderGroup({ config, onToggleGizmo })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Rotate' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
expect(config.gizmo?.enabled).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'Rotate' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets the transform mode', async () => {
|
||||
const config = makeConfig(true)
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const { user } = renderGroup({ config, onSetGizmoMode })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }))
|
||||
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
expect(config.gizmo?.mode).toBe('rotate')
|
||||
})
|
||||
})
|
||||
105
src/components/load3d/menubar/GizmoMenuGroup.vue
Normal file
105
src/components/load3d/menubar/GizmoMenuGroup.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.toggle'))"
|
||||
:class="actionClass(gizmoEnabled)"
|
||||
:aria-pressed="gizmoEnabled"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.toggle') : undefined"
|
||||
@click="toggleGizmo"
|
||||
>
|
||||
<i class="icon-[lucide--axis-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.toggle') }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.translate'))"
|
||||
:class="actionClass(gizmoMode === 'translate')"
|
||||
:aria-pressed="gizmoMode === 'translate'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.translate') : undefined"
|
||||
@click="setGizmoMode('translate')"
|
||||
>
|
||||
<i class="icon-[lucide--move] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.translate') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.rotate'))"
|
||||
:class="actionClass(gizmoMode === 'rotate')"
|
||||
:aria-pressed="gizmoMode === 'rotate'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.rotate') : undefined"
|
||||
@click="setGizmoMode('rotate')"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.rotate') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.scale'))"
|
||||
:class="actionClass(gizmoMode === 'scale')"
|
||||
:aria-pressed="gizmoMode === 'scale'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.scale') : undefined"
|
||||
@click="setGizmoMode('scale')"
|
||||
>
|
||||
<i class="icon-[lucide--scale-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.scale') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.reset'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.reset') : undefined"
|
||||
@click="resetGizmoTransform"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.reset') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
|
||||
import type {
|
||||
GizmoMode,
|
||||
ModelConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<ModelConfig>('config')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gizmoEnabled = computed(() => config.value?.gizmo?.enabled ?? false)
|
||||
const gizmoMode = computed(() => config.value?.gizmo?.mode ?? 'translate')
|
||||
|
||||
function toggleGizmo() {
|
||||
const gizmo = config.value?.gizmo
|
||||
if (!gizmo) return
|
||||
gizmo.enabled = !gizmo.enabled
|
||||
emit('toggleGizmo', gizmo.enabled)
|
||||
}
|
||||
|
||||
function setGizmoMode(mode: GizmoMode) {
|
||||
const gizmo = config.value?.gizmo
|
||||
if (!gizmo) return
|
||||
gizmo.mode = mode
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
function resetGizmoTransform() {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
74
src/components/load3d/menubar/HdriMenuGroup.test.ts
Normal file
74
src/components/load3d/menubar/HdriMenuGroup.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
LightConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(hdri?: Partial<HDRIConfig>): LightConfig {
|
||||
return {
|
||||
intensity: 5,
|
||||
hdri: hdri
|
||||
? {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1,
|
||||
...hdri
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config?: LightConfig
|
||||
sceneHasImage?: boolean
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props = {}) {
|
||||
const result = render(HdriMenuGroup, {
|
||||
props: { config: makeConfig({}), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('HdriMenuGroup', () => {
|
||||
it('shows the upload button when no HDRI is loaded', () => {
|
||||
renderGroup()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the upload when a background image is set and no HDRI exists', () => {
|
||||
renderGroup({ config: makeConfig({ hdriPath: '' }), sceneHasImage: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Upload' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles enabled and forwards removal once a file is loaded', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const config = makeConfig({ hdriPath: 'env.hdr', enabled: false })
|
||||
const { user } = renderGroup({ config, onUpdateHdriFile })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'HDRI' }))
|
||||
expect(config.hdri?.enabled).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
130
src/components/load3d/menubar/HdriMenuGroup.vue
Normal file
130
src/components/load3d/menubar/HdriMenuGroup.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<template v-if="!sceneHasImage || hdriPath">
|
||||
<button
|
||||
v-tooltip.bottom="
|
||||
tip(
|
||||
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
|
||||
)
|
||||
"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="
|
||||
compact
|
||||
? hdriPath
|
||||
? t('load3d.hdri.changeFile')
|
||||
: t('load3d.hdri.uploadFile')
|
||||
: undefined
|
||||
"
|
||||
@click="hdriFileRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
<span v-if="!compact">{{
|
||||
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
|
||||
}}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="hdriFileRef"
|
||||
type="file"
|
||||
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@change="onHdriFilePicked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="hdriPath">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.label'))"
|
||||
:class="actionClass(hdriEnabled)"
|
||||
:aria-pressed="hdriEnabled"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.label') : undefined"
|
||||
@click="toggleHdriEnabled"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.label') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.showAsBackground'))"
|
||||
:class="actionClass(hdriShowAsBackground)"
|
||||
:aria-pressed="hdriShowAsBackground"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.showAsBackground') : undefined"
|
||||
@click="toggleHdriShowAsBackground"
|
||||
>
|
||||
<i class="icon-[lucide--image] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.showAsBackground') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.removeFile'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.removeFile') : undefined"
|
||||
@click="removeHdri"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.removeFile') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
|
||||
import {
|
||||
SUPPORTED_HDRI_EXTENSIONS,
|
||||
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
const { compact = false, sceneHasImage = false } = defineProps<{
|
||||
compact?: boolean
|
||||
sceneHasImage?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<LightConfig>('config')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hdriPath = computed(() => config.value?.hdri?.hdriPath ?? '')
|
||||
const hdriEnabled = computed(() => config.value?.hdri?.enabled ?? false)
|
||||
const hdriShowAsBackground = computed(
|
||||
() => config.value?.hdri?.showAsBackground ?? false
|
||||
)
|
||||
|
||||
const hdriFileRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onHdriFilePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
if (file) {
|
||||
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
|
||||
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
|
||||
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
function toggleHdriEnabled() {
|
||||
const hdri = config.value?.hdri
|
||||
if (hdri) hdri.enabled = !hdri.enabled
|
||||
}
|
||||
|
||||
function toggleHdriShowAsBackground() {
|
||||
const hdri = config.value?.hdri
|
||||
if (hdri) hdri.showAsBackground = !hdri.showAsBackground
|
||||
}
|
||||
|
||||
function removeHdri() {
|
||||
emit('updateHdriFile', null)
|
||||
}
|
||||
</script>
|
||||
74
src/components/load3d/menubar/LightMenuGroup.test.ts
Normal file
74
src/components/load3d/menubar/LightMenuGroup.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const settingValues: Record<string, number> = {
|
||||
'Comfy.Load3D.LightIntensityMinimum': 1,
|
||||
'Comfy.Load3D.LightIntensityMaximum': 10,
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': 0.1
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (key: string) => settingValues[key] })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderGroup(isOriginalMaterial: boolean) {
|
||||
const config: LightConfig = { intensity: 5 }
|
||||
return render(LightMenuGroup, {
|
||||
props: { config, isOriginalMaterial },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LightMenuGroup', () => {
|
||||
it('shows the intensity control for the original material', () => {
|
||||
renderGroup(true)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Intensity' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains intensity is unavailable for other materials', () => {
|
||||
renderGroup(false)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Original material only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drives HDRI intensity (0-5) when an HDRI environment is active', async () => {
|
||||
const config: LightConfig = {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 2
|
||||
}
|
||||
}
|
||||
render(LightMenuGroup, {
|
||||
props: { config, isOriginalMaterial: true },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '5')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
})
|
||||
105
src/components/load3d/menubar/LightMenuGroup.vue
Normal file
105
src/components/load3d/menubar/LightMenuGroup.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<Popover v-if="isOriginalMaterial">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.intensity'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.intensity') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--sun] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.intensity') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('load3d.lightIntensity')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="[sliderValue]"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
class="w-full"
|
||||
@update:model-value="onIntensityUpdate"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span v-else class="px-2 text-sm text-muted">{{
|
||||
t('load3d.menuBar.originalMaterialOnly')
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const { compact = false, isOriginalMaterial = false } = defineProps<{
|
||||
compact?: boolean
|
||||
isOriginalMaterial?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<LightConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const lightIntensityMinimum = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMinimum'
|
||||
)
|
||||
const lightIntensityMaximum = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
)
|
||||
const lightAdjustmentIncrement = settingStore.get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const usesHdriIntensity = computed(
|
||||
() => !!config.value?.hdri?.hdriPath?.length && !!config.value?.hdri?.enabled
|
||||
)
|
||||
|
||||
const sliderMin = computed(() =>
|
||||
usesHdriIntensity.value ? 0 : lightIntensityMinimum
|
||||
)
|
||||
const sliderMax = computed(() =>
|
||||
usesHdriIntensity.value ? 5 : lightIntensityMaximum
|
||||
)
|
||||
const sliderStep = computed(() =>
|
||||
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
|
||||
)
|
||||
const sliderValue = computed(() =>
|
||||
usesHdriIntensity.value
|
||||
? (config.value?.hdri?.intensity ?? 1)
|
||||
: (config.value?.intensity ?? lightIntensityMinimum)
|
||||
)
|
||||
|
||||
function onIntensityUpdate(value?: number[]) {
|
||||
if (!value?.length || !config.value) return
|
||||
const next = value[0]
|
||||
if (usesHdriIntensity.value) {
|
||||
if (config.value.hdri) config.value.hdri.intensity = next
|
||||
} else {
|
||||
config.value.intensity = next
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
src/components/load3d/menubar/ModelMenuGroup.test.ts
Normal file
69
src/components/load3d/menubar/ModelMenuGroup.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
|
||||
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<ModelConfig> = {}): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroup(
|
||||
props: { config?: ModelConfig; hasSkeleton?: boolean } = {}
|
||||
) {
|
||||
const result = render(ModelMenuGroup, {
|
||||
props: { config: makeConfig(), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ModelMenuGroup', () => {
|
||||
it('sets the up direction from the popover', async () => {
|
||||
const config = makeConfig()
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Up Direction' }))
|
||||
await user.click(screen.getByRole('button', { name: '+Y' }))
|
||||
|
||||
expect(config.upDirection).toBe('+y')
|
||||
})
|
||||
|
||||
it('sets the material mode from the popover', async () => {
|
||||
const config = makeConfig()
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Material' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Wireframe' }))
|
||||
|
||||
expect(config.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('toggles the skeleton only when supported', async () => {
|
||||
const config = makeConfig({ showSkeleton: false })
|
||||
const { user, rerender } = renderGroup({ config, hasSkeleton: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Skeleton' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ config, hasSkeleton: true })
|
||||
await user.click(screen.getByRole('button', { name: 'Skeleton' }))
|
||||
|
||||
expect(config.showSkeleton).toBe(true)
|
||||
})
|
||||
})
|
||||
135
src/components/load3d/menubar/ModelMenuGroup.vue
Normal file
135
src/components/load3d/menubar/ModelMenuGroup.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.upDirection'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.upDirection') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--move-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.upDirection') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="d in upDirections"
|
||||
:key="d"
|
||||
type="button"
|
||||
:class="cn(rowClass, upDirection === d && 'bg-button-active-surface')"
|
||||
@click="setUpDirection(d)"
|
||||
>
|
||||
{{ d.toUpperCase() }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover v-if="materialModes.length">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.material'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.material') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--box] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.material') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="m in materialModes"
|
||||
:key="m"
|
||||
type="button"
|
||||
:class="cn(rowClass, materialMode === m && 'bg-button-active-surface')"
|
||||
@click="setMaterialMode(m)"
|
||||
>
|
||||
{{ t(`load3d.materialModes.${m}`) }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
v-if="hasSkeleton"
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.skeleton'))"
|
||||
:class="actionClass(showSkeleton)"
|
||||
:aria-pressed="showSkeleton"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.skeleton') : undefined"
|
||||
@click="toggleSkeleton"
|
||||
>
|
||||
<i class="icon-[lucide--bone] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.skeleton') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const {
|
||||
compact = false,
|
||||
hasSkeleton = false,
|
||||
materialModes = ['original', 'clay', 'normal', 'wireframe']
|
||||
} = defineProps<{
|
||||
compact?: boolean
|
||||
hasSkeleton?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
}>()
|
||||
|
||||
const config = defineModel<ModelConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const upDirection = computed(() => config.value?.upDirection)
|
||||
const materialMode = computed(() => config.value?.materialMode)
|
||||
const showSkeleton = computed(() => config.value?.showSkeleton ?? false)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
'-x',
|
||||
'+x',
|
||||
'-y',
|
||||
'+y',
|
||||
'-z',
|
||||
'+z'
|
||||
]
|
||||
|
||||
function setUpDirection(direction: UpDirection) {
|
||||
if (config.value) config.value.upDirection = direction
|
||||
}
|
||||
|
||||
function setMaterialMode(mode: MaterialMode) {
|
||||
if (config.value) config.value.materialMode = mode
|
||||
}
|
||||
|
||||
function toggleSkeleton() {
|
||||
if (config.value) config.value.showSkeleton = !config.value.showSkeleton
|
||||
}
|
||||
</script>
|
||||
138
src/components/load3d/menubar/RecordMenuControl.test.ts
Normal file
138
src/components/load3d/menubar/RecordMenuControl.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
type Props = {
|
||||
isRecording?: boolean
|
||||
hasRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderControl(props: Props = {}) {
|
||||
const result = render(RecordMenuControl, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function openRecordingMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
|
||||
)
|
||||
}
|
||||
|
||||
describe('RecordMenuControl', () => {
|
||||
it('starts recording when idle', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderControl({ isRecording: false, onStartRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Record' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops recording when active', async () => {
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderControl({ isRecording: true, onStopRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the recording duration once a recording exists', () => {
|
||||
renderControl({
|
||||
isRecording: false,
|
||||
hasRecording: true,
|
||||
recordingDuration: 65
|
||||
})
|
||||
|
||||
expect(screen.getByText('01:05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the recording from the menu', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onExportRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts a new recording from the menu', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onStartRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Start New Recording' })
|
||||
)
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deletes the recording from the menu', async () => {
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', {
|
||||
name: 'Delete Recording'
|
||||
})
|
||||
)
|
||||
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deletes the recording via the chip dismiss button', async () => {
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Recording' }))
|
||||
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows only the stop control while recording is in progress', () => {
|
||||
renderControl({ isRecording: true, hasRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Video recording of the scene [mp4]'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
141
src/components/load3d/menubar/RecordMenuControl.vue
Normal file
141
src/components/load3d/menubar/RecordMenuControl.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="isRecording"
|
||||
v-tooltip.top="tip(t('load3d.menuBar.stopRecording'))"
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.stopRecording')"
|
||||
@click="emit('stopRecording')"
|
||||
>
|
||||
<span class="size-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.recording') }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-else-if="hasRecording"
|
||||
class="flex shrink-0 items-center gap-0.5 rounded-lg bg-button-active-surface py-0.5 pr-0.5 pl-1 text-sm text-base-foreground"
|
||||
>
|
||||
<Popover v-model:open="menuOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.menuBar.videoRecordingTooltip'))"
|
||||
class="focus-visible:ring-ring flex items-center gap-1.5 rounded-md border-0 bg-transparent px-1 py-0.5 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.videoRecordingTooltip')"
|
||||
data-testid="load3d-recording-duration"
|
||||
>
|
||||
<i class="icon-[lucide--film] size-4" />
|
||||
{{ formatDuration(recordingDuration ?? 0) }}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
{{ t('load3d.menuBar.downloadRecording') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="startNewRecording"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
{{ t('load3d.menuBar.startNewRecording') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="deleteRecording"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ t('load3d.menuBar.deleteRecording') }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.menuBar.deleteRecording'))"
|
||||
class="focus-visible:ring-ring flex size-6 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.deleteRecording')"
|
||||
@click="emit('clearRecording')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip.top="tip(t('load3d.menuBar.record'))"
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.record') : undefined"
|
||||
@click="emit('startRecording')"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.record') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
chipClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
function downloadRecording() {
|
||||
menuOpen.value = false
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
function startNewRecording() {
|
||||
menuOpen.value = false
|
||||
emit('startRecording')
|
||||
}
|
||||
|
||||
function deleteRecording() {
|
||||
menuOpen.value = false
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
108
src/components/load3d/menubar/SceneMenuGroup.test.ts
Normal file
108
src/components/load3d/menubar/SceneMenuGroup.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
|
||||
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<SceneConfig> = {}): SceneConfig {
|
||||
return {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config?: SceneConfig
|
||||
fov?: number
|
||||
hdriActive?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props = {}) {
|
||||
const result = render(SceneMenuGroup, {
|
||||
props: { config: makeConfig(), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('SceneMenuGroup', () => {
|
||||
it('toggles showGrid on the bound config', async () => {
|
||||
const config = makeConfig({ showGrid: true })
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(config.showGrid).toBe(false)
|
||||
})
|
||||
|
||||
it('hides background color and image controls while HDRI is active', () => {
|
||||
renderGroup({ hdriActive: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Color' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the image upload when background images are not allowed', () => {
|
||||
renderGroup({ canUseBackgroundImage: false })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'BG Color' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows panorama and remove once a background image exists', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderGroup({
|
||||
config: makeConfig({ backgroundImage: 'bg.png' }),
|
||||
onUpdateBackgroundImage
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Panorama' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Remove BG' }))
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('exposes the FOV control while a panorama background is active', () => {
|
||||
renderGroup({
|
||||
config: makeConfig({
|
||||
backgroundImage: 'bg.png',
|
||||
backgroundRenderMode: 'panorama'
|
||||
}),
|
||||
fov: 75
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'FOV' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears the file input so the same image can be re-picked', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderGroup({ onUpdateBackgroundImage })
|
||||
const input = screen.getByTestId<HTMLInputElement>('scene-bg-image-input')
|
||||
const file = new File(['x'], 'bg.png', { type: 'image/png' })
|
||||
|
||||
await user.upload(input, file)
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
190
src/components/load3d/menubar/SceneMenuGroup.vue
Normal file
190
src/components/load3d/menubar/SceneMenuGroup.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.showGrid'))"
|
||||
:class="actionClass(showGrid)"
|
||||
:aria-pressed="showGrid"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.showGrid') : undefined"
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.showGrid') }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!hasImage && !hdriActive">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.bgColor'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.bgColor') : undefined"
|
||||
@click="colorRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--palette] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.bgColor') }}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="colorRef"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
:value="bgColor"
|
||||
@input="setBackgroundColor"
|
||||
/>
|
||||
<template v-if="canUseBackgroundImage">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.bgImage'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.bgImage') : undefined"
|
||||
@click="bgImageRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--image] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.bgImage') }}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="bgImageRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
data-testid="scene-bg-image-input"
|
||||
@change="onBackgroundImagePicked"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="hasImage">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.panorama'))"
|
||||
:class="actionClass(isPanorama)"
|
||||
:aria-pressed="isPanorama"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.panorama') : undefined"
|
||||
@click="togglePanorama"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.panorama') }}</span>
|
||||
</button>
|
||||
<Popover v-if="isPanorama">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('load3d.fov')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="[fovValue]"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="setFov"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.removeBackground'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.removeBackground') : undefined"
|
||||
@click="removeBackgroundImage"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.removeBackground') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const {
|
||||
compact = false,
|
||||
canUseBackgroundImage = true,
|
||||
hdriActive = false
|
||||
} = defineProps<{
|
||||
compact?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
hdriActive?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<SceneConfig>('config')
|
||||
const fov = defineModel<number>('fov')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showGrid = computed(() => config.value?.showGrid ?? false)
|
||||
const bgColor = computed(() => config.value?.backgroundColor ?? '#000000')
|
||||
const hasImage = computed(
|
||||
() => !!config.value?.backgroundImage && config.value.backgroundImage !== ''
|
||||
)
|
||||
const isPanorama = computed(
|
||||
() => config.value?.backgroundRenderMode === 'panorama'
|
||||
)
|
||||
const fovValue = computed(() => fov.value ?? 10)
|
||||
|
||||
const colorRef = ref<HTMLInputElement | null>(null)
|
||||
const bgImageRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function toggleGrid() {
|
||||
if (config.value) config.value.showGrid = !config.value.showGrid
|
||||
}
|
||||
|
||||
function setBackgroundColor(event: Event) {
|
||||
if (config.value) {
|
||||
config.value.backgroundColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundImagePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (file) emit('updateBackgroundImage', file)
|
||||
}
|
||||
|
||||
function removeBackgroundImage() {
|
||||
emit('updateBackgroundImage', null)
|
||||
}
|
||||
|
||||
function togglePanorama() {
|
||||
if (!config.value) return
|
||||
config.value.backgroundRenderMode =
|
||||
config.value.backgroundRenderMode === 'panorama' ? 'tiled' : 'panorama'
|
||||
}
|
||||
|
||||
function setFov(value?: number[]) {
|
||||
if (value?.length) fov.value = value[0]
|
||||
}
|
||||
</script>
|
||||
24
src/components/load3d/menubar/menuBarStyles.ts
Normal file
24
src/components/load3d/menubar/menuBarStyles.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
export const chipClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-lg border-0 bg-interface-menu-surface px-2.5 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-button-active-surface focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
export const iconBtnClass =
|
||||
'flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground outline-none transition-colors hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
export const panelClass =
|
||||
'w-48 max-h-80 overflow-y-auto flex flex-col gap-0.5 p-1.5 rounded-lg border-border-default bg-interface-menu-surface shadow-interface'
|
||||
|
||||
export const rowClass =
|
||||
'flex w-full cursor-pointer items-center rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-sm text-base-foreground outline-none hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset'
|
||||
|
||||
export function actionClass(active: boolean) {
|
||||
return cn(
|
||||
'focus-visible:ring-ring flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1',
|
||||
active && 'bg-button-active-surface'
|
||||
)
|
||||
}
|
||||
|
||||
export function tip(label: string) {
|
||||
return { value: label, showDelay: 300 }
|
||||
}
|
||||
@@ -214,9 +214,7 @@ describe('useLoad3d', () => {
|
||||
getModelInfo: vi.fn().mockReturnValue(null),
|
||||
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
setAnimationTime: vi.fn(),
|
||||
renderer: {
|
||||
domElement: mockCanvas
|
||||
} as Partial<Load3d['renderer']> as Load3d['renderer']
|
||||
domElement: mockCanvas
|
||||
}
|
||||
|
||||
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
|
||||
@@ -303,7 +301,7 @@ describe('useLoad3d', () => {
|
||||
mockNode.flags.collapsed = true
|
||||
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
|
||||
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
|
||||
expect(mockLoad3d.domElement!.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
|
||||
|
||||
@@ -180,6 +180,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
@@ -243,7 +244,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
load3d.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -43,13 +43,8 @@ function makeMockEventManager() {
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeRenderer(opts: { withParent?: boolean } = {}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
if (opts.withParent) {
|
||||
const parent = document.createElement('div')
|
||||
parent.appendChild(canvas)
|
||||
}
|
||||
return { domElement: canvas } as unknown as THREE.WebGLRenderer
|
||||
function makeElement() {
|
||||
return document.createElement('div')
|
||||
}
|
||||
|
||||
describe('ControlsManager', () => {
|
||||
@@ -64,33 +59,19 @@ describe('ControlsManager', () => {
|
||||
})
|
||||
|
||||
describe('construction', () => {
|
||||
it('attaches OrbitControls to the canvas parent when one exists', () => {
|
||||
const renderer = makeRenderer({ withParent: true })
|
||||
it('attaches OrbitControls to the interaction element', () => {
|
||||
const element = makeElement()
|
||||
|
||||
manager = new ControlsManager(renderer, camera, events)
|
||||
manager = new ControlsManager(element, camera, events)
|
||||
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(
|
||||
camera,
|
||||
renderer.domElement.parentElement
|
||||
)
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(camera, element)
|
||||
expect(manager.controls.enableDamping).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the canvas itself when there is no parent', () => {
|
||||
const renderer = makeRenderer({ withParent: false })
|
||||
|
||||
manager = new ControlsManager(renderer, camera, events)
|
||||
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(
|
||||
camera,
|
||||
renderer.domElement
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('emits cameraChanged with a perspective state when the controls fire end', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
camera.position.set(1, 2, 3)
|
||||
camera.zoom = 1.25
|
||||
manager.controls.target.set(4, 5, 6)
|
||||
@@ -109,7 +90,7 @@ describe('ControlsManager', () => {
|
||||
it('reports orthographic camera type when initialized with one', () => {
|
||||
const ortho = new THREE.OrthographicCamera()
|
||||
ortho.zoom = 0.5
|
||||
manager = new ControlsManager(makeRenderer(), ortho, events)
|
||||
manager = new ControlsManager(makeElement(), ortho, events)
|
||||
manager.init()
|
||||
|
||||
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
|
||||
@@ -123,7 +104,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('updateCamera', () => {
|
||||
it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
camera.position.set(7, 8, 9)
|
||||
manager.controls.target.set(1, 1, 1)
|
||||
|
||||
@@ -139,7 +120,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('update / reset', () => {
|
||||
it('update delegates to controls.update', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
|
||||
manager.update()
|
||||
|
||||
@@ -147,7 +128,7 @@ describe('ControlsManager', () => {
|
||||
})
|
||||
|
||||
it('reset clears the target back to the origin and refreshes', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager.controls.target.set(5, 6, 7)
|
||||
|
||||
manager.reset()
|
||||
@@ -159,7 +140,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes the underlying OrbitControls', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
@@ -169,7 +150,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('detach / attach', () => {
|
||||
it('detach disables OrbitControls interaction', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
expect(manager.controls.enabled).toBe(true)
|
||||
|
||||
manager.detach()
|
||||
@@ -178,7 +159,7 @@ describe('ControlsManager', () => {
|
||||
})
|
||||
|
||||
it('attach re-enables OrbitControls interaction', () => {
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager.detach()
|
||||
|
||||
manager.attach()
|
||||
|
||||
@@ -12,15 +12,14 @@ export class ControlsManager implements ControlsManagerInterface {
|
||||
private camera: THREE.Camera
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
interactionElement: HTMLElement,
|
||||
camera: THREE.Camera,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.eventManager = eventManager
|
||||
this.camera = camera
|
||||
|
||||
const container = renderer.domElement.parentElement || renderer.domElement
|
||||
this.controls = new OrbitControls(camera, container)
|
||||
this.controls = new OrbitControls(camera, interactionElement)
|
||||
this.controls.enableDamping = true
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ function makeMockOrbitControls() {
|
||||
|
||||
describe('GizmoManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let interactionElement: HTMLElement
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let orbitControls: ReturnType<typeof makeMockOrbitControls>
|
||||
let manager: GizmoManager
|
||||
@@ -66,9 +66,7 @@ describe('GizmoManager', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
scene = new THREE.Scene()
|
||||
renderer = {
|
||||
domElement: document.createElement('canvas')
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
interactionElement = document.createElement('div')
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
orbitControls = makeMockOrbitControls()
|
||||
onTransformChange = vi.fn()
|
||||
@@ -80,7 +78,7 @@ describe('GizmoManager', () => {
|
||||
|
||||
manager = new GizmoManager(
|
||||
scene,
|
||||
renderer,
|
||||
interactionElement,
|
||||
orbitControls,
|
||||
() => camera,
|
||||
onTransformChange
|
||||
|
||||
@@ -15,19 +15,19 @@ export class GizmoManager {
|
||||
private activeCamera: THREE.Camera
|
||||
private mode: GizmoMode = 'translate'
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private interactionElement: HTMLElement
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
interactionElement: HTMLElement,
|
||||
orbitControls: OrbitControls,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
onTransformChange?: () => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.interactionElement = interactionElement
|
||||
this.orbitControls = orbitControls
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.onTransformChange = onTransformChange
|
||||
@@ -36,7 +36,7 @@ export class GizmoManager {
|
||||
init(): void {
|
||||
this.transformControls = new TransformControls(
|
||||
this.activeCamera,
|
||||
this.renderer.domElement
|
||||
this.interactionElement
|
||||
)
|
||||
|
||||
this.transformControls.addEventListener('dragging-changed', (event) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
|
||||
@@ -76,7 +78,12 @@ describe('HDRIManager', () => {
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
|
||||
manager = new HDRIManager(
|
||||
scene,
|
||||
{} as THREE.WebGLRenderer,
|
||||
createRendererViewState(),
|
||||
eventManager
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,12 +2,14 @@ import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
|
||||
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
export class HDRIManager {
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private viewState: RendererViewState
|
||||
private pmremGenerator: THREE.PMREMGenerator
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
@@ -33,10 +35,11 @@ export class HDRIManager {
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
viewState: RendererViewState,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.viewState = viewState
|
||||
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
|
||||
this.pmremGenerator.compileEquirectangularShader()
|
||||
this.eventManager = eventManager
|
||||
@@ -101,8 +104,8 @@ export class HDRIManager {
|
||||
this.scene.environment = envMap
|
||||
this.scene.environmentIntensity = this._intensity
|
||||
this.scene.background = this._showAsBackground ? this.hdriTexture : null
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.viewState.toneMapping = THREE.ACESFilmicToneMapping
|
||||
this.viewState.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: this._isEnabled,
|
||||
showAsBackground: this._showAsBackground
|
||||
@@ -114,8 +117,8 @@ export class HDRIManager {
|
||||
if (this.scene.background === this.hdriTexture) {
|
||||
this.scene.background = null
|
||||
}
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.viewState.toneMapping = THREE.NoToneMapping
|
||||
this.viewState.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: false,
|
||||
showAsBackground: this._showAsBackground
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('Load3d', () => {
|
||||
const sceneResize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
|
||||
view: { canvas, setSize },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
@@ -361,26 +361,21 @@ describe('Load3d', () => {
|
||||
const updateAspectRatio = vi.fn()
|
||||
const renderBackground = vi.fn()
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
const scene = {} as THREE.Scene
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: {
|
||||
domElement: canvas,
|
||||
setViewport,
|
||||
setScissor,
|
||||
setScissorTest,
|
||||
setClearColor,
|
||||
clear,
|
||||
render
|
||||
view: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
|
||||
renderer: {
|
||||
setViewport,
|
||||
setScissor,
|
||||
setScissorTest,
|
||||
setClearColor,
|
||||
clear,
|
||||
render
|
||||
}
|
||||
},
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
@@ -415,7 +410,7 @@ describe('Load3d', () => {
|
||||
})
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas },
|
||||
view: { canvas },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
@@ -438,7 +433,7 @@ describe('Load3d', () => {
|
||||
expect(args[3]).toBe(400)
|
||||
})
|
||||
|
||||
it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
|
||||
it('handleResize scales the view size by getZoomScaleCallback', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
@@ -453,10 +448,10 @@ describe('Load3d', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setPixelRatio = vi.fn()
|
||||
const setSize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
|
||||
view: { canvas, setSize },
|
||||
getZoomScaleCallback: () => 2.5,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
@@ -467,10 +462,10 @@ describe('Load3d', () => {
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setPixelRatio).toHaveBeenCalledWith(2.5)
|
||||
expect(setSize).toHaveBeenCalledWith(1000, 1000)
|
||||
})
|
||||
|
||||
it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
|
||||
it('handleResize caps the zoom scale at 3', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
@@ -485,10 +480,42 @@ describe('Load3d', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setPixelRatio = vi.fn()
|
||||
const setSize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
|
||||
view: { canvas, setSize },
|
||||
getZoomScaleCallback: () => 10,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
isViewerMode: false,
|
||||
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
|
||||
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
|
||||
})
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setSize).toHaveBeenCalledWith(1200, 1200)
|
||||
})
|
||||
|
||||
it('handleResize defaults to scale 1 when no getZoomScaleCallback is provided', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
Object.defineProperty(parent, 'clientWidth', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(parent, 'clientHeight', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setSize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: { canvas, setSize },
|
||||
getZoomScaleCallback: undefined,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
@@ -499,7 +526,7 @@ describe('Load3d', () => {
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setPixelRatio).toHaveBeenCalledWith(1)
|
||||
expect(setSize).toHaveBeenCalledWith(400, 400)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -510,7 +537,8 @@ describe('Load3d', () => {
|
||||
const viewHelperRender = vi.fn()
|
||||
const controlsUpdate = vi.fn()
|
||||
const renderMainScene = vi.fn()
|
||||
const resetViewport = vi.fn()
|
||||
const beginRender = vi.fn()
|
||||
const blit = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
STATUS_MOUSE_ON_NODE: true,
|
||||
@@ -525,13 +553,16 @@ describe('Load3d', () => {
|
||||
},
|
||||
viewHelperManager: {
|
||||
update: viewHelperUpdate,
|
||||
viewHelper: { render: viewHelperRender }
|
||||
render: viewHelperRender
|
||||
},
|
||||
controlsManager: { update: controlsUpdate },
|
||||
recordingManager: { getIsRecording: vi.fn(() => false) },
|
||||
renderMainScene,
|
||||
resetViewport,
|
||||
renderer: {}
|
||||
view: {
|
||||
beginRender,
|
||||
blit,
|
||||
renderer: { setScissorTest: vi.fn() }
|
||||
}
|
||||
})
|
||||
|
||||
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
|
||||
@@ -546,9 +577,10 @@ describe('Load3d', () => {
|
||||
expect(animationUpdate).toHaveBeenCalledOnce()
|
||||
expect(viewHelperUpdate).toHaveBeenCalledOnce()
|
||||
expect(controlsUpdate).toHaveBeenCalledOnce()
|
||||
expect(beginRender).toHaveBeenCalledOnce()
|
||||
expect(renderMainScene).toHaveBeenCalledOnce()
|
||||
expect(resetViewport).toHaveBeenCalledOnce()
|
||||
expect(viewHelperRender).toHaveBeenCalledOnce()
|
||||
expect(blit).toHaveBeenCalledOnce()
|
||||
|
||||
// Cancel the queued rAF so the test doesn't leak frames.
|
||||
loop.stop()
|
||||
@@ -560,12 +592,10 @@ describe('Load3d', () => {
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderLoop: { stop },
|
||||
resizeObserver: null,
|
||||
contextMenuAbortController: null,
|
||||
renderer: {
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
domElement: canvas
|
||||
view: {
|
||||
canvas,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
|
||||
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },
|
||||
|
||||
@@ -67,7 +67,7 @@ class Load3d extends Viewport3d {
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
container: HTMLElement,
|
||||
deps: Load3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
@@ -255,8 +255,8 @@ class Load3d extends Viewport3d {
|
||||
this.sceneManager.backgroundTexture &&
|
||||
this.sceneManager.backgroundMesh
|
||||
) {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
const containerWidth = this.domElement.clientWidth
|
||||
const containerHeight = this.domElement.clientHeight
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('MeshModelAdapter', () => {
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
@@ -24,7 +24,7 @@ export class MeshModelAdapter implements ModelAdapter {
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
materialModes: ['original', 'clay', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('DEFAULT_MODEL_CAPABILITIES', () => {
|
||||
expect(DEFAULT_MODEL_CAPABILITIES.exportable).toBe(true)
|
||||
expect([...DEFAULT_MODEL_CAPABILITIES.materialModes]).toEqual([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
@@ -60,7 +60,7 @@ export const DEFAULT_MODEL_CAPABILITIES: ModelAdapterCapabilities = {
|
||||
gizmoTransform: true,
|
||||
lighting: true,
|
||||
exportable: true,
|
||||
materialModes: ['original', 'normal', 'wireframe'],
|
||||
materialModes: ['original', 'clay', 'normal', 'wireframe'],
|
||||
fitTargetSize: 5
|
||||
}
|
||||
|
||||
|
||||
@@ -64,16 +64,16 @@ function makeStream(): MediaStream {
|
||||
} as unknown as MediaStream
|
||||
}
|
||||
|
||||
function makeRenderer(): THREE.WebGLRenderer {
|
||||
function makeSourceCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 600
|
||||
return { domElement: canvas } as unknown as THREE.WebGLRenderer
|
||||
return canvas
|
||||
}
|
||||
|
||||
describe('RecordingManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let sourceCanvas: HTMLCanvasElement
|
||||
let events: ReturnType<typeof makeMockEventManager>
|
||||
let manager: RecordingManager
|
||||
let rafSpy: ReturnType<typeof vi.spyOn>
|
||||
@@ -104,9 +104,9 @@ describe('RecordingManager', () => {
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
|
||||
scene = new THREE.Scene()
|
||||
renderer = makeRenderer()
|
||||
sourceCanvas = makeSourceCanvas()
|
||||
events = makeMockEventManager()
|
||||
manager = new RecordingManager(scene, renderer, events)
|
||||
manager = new RecordingManager(scene, sourceCanvas, events)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ export class RecordingManager {
|
||||
private recordingStream: MediaStream | null = null
|
||||
private recordingIndicator: THREE.Sprite | null = null
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private sourceCanvas: HTMLCanvasElement
|
||||
private eventManager: EventManagerInterface
|
||||
private recordingStartTime: number = 0
|
||||
private recordingDuration: number = 0
|
||||
@@ -21,11 +21,11 @@ export class RecordingManager {
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
sourceCanvas: HTMLCanvasElement,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.sourceCanvas = sourceCanvas
|
||||
this.eventManager = eventManager
|
||||
this.setupRecordingIndicator()
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export class RecordingManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceCanvas = this.renderer.domElement
|
||||
const sourceCanvas = this.sourceCanvas
|
||||
const sourceWidth = sourceCanvas.width
|
||||
const sourceHeight = sourceCanvas.height
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import * as THREE from 'three'
|
||||
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { RendererView } from '@/renderer/three/RendererView'
|
||||
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import { SceneManager } from './SceneManager'
|
||||
@@ -65,6 +68,32 @@ function makeMockEventManager() {
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeView(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
width = 400,
|
||||
height = 300
|
||||
): RendererView {
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
configurable: true,
|
||||
value: width
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: height
|
||||
})
|
||||
return {
|
||||
renderer,
|
||||
canvas,
|
||||
state: createRendererViewState(),
|
||||
width,
|
||||
height,
|
||||
beginRender: vi.fn(),
|
||||
blit: vi.fn(),
|
||||
setSize: vi.fn()
|
||||
} as unknown as RendererView
|
||||
}
|
||||
|
||||
function makeRenderer() {
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
@@ -120,7 +149,7 @@ describe('SceneManager', () => {
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
events = makeMockEventManager()
|
||||
manager = new SceneManager(
|
||||
renderer,
|
||||
makeView(renderer),
|
||||
() => camera,
|
||||
() => ({}) as unknown as OrbitControls,
|
||||
events
|
||||
@@ -592,20 +621,22 @@ describe('SceneManager', () => {
|
||||
|
||||
function makeSceneManager(
|
||||
pixelRatio = 1,
|
||||
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera
|
||||
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera,
|
||||
viewSize?: { width: number; height: number }
|
||||
) {
|
||||
const renderer = makeMockRenderer(pixelRatio)
|
||||
const view = makeView(renderer, viewSize?.width, viewSize?.height)
|
||||
const camera = cameraOverride ?? new THREE.PerspectiveCamera()
|
||||
const eventManager = makeMockEventManager()
|
||||
const manager = new SceneManager(
|
||||
renderer,
|
||||
view,
|
||||
() => camera,
|
||||
vi.fn() as unknown as () => InstanceType<
|
||||
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
|
||||
>,
|
||||
eventManager
|
||||
)
|
||||
return { manager, renderer, camera, eventManager }
|
||||
return { manager, renderer, view, camera, eventManager }
|
||||
}
|
||||
|
||||
describe('SceneManager.captureScene', () => {
|
||||
@@ -648,6 +679,19 @@ describe('SceneManager.captureScene', () => {
|
||||
expect(calls.at(-1)).toEqual([400, 300])
|
||||
})
|
||||
|
||||
it('restores the view state first and resizes the background to the view size, not the shared buffer size', async () => {
|
||||
const { manager, view } = makeSceneManager(1, undefined, {
|
||||
width: 640,
|
||||
height: 480
|
||||
})
|
||||
const handleResize = vi.spyOn(manager, 'handleResize')
|
||||
|
||||
await manager.captureScene(1920, 1080)
|
||||
|
||||
expect(view.beginRender).toHaveBeenCalledOnce()
|
||||
expect(handleResize).toHaveBeenCalledWith(640, 480)
|
||||
})
|
||||
|
||||
it('restores perspective camera aspect after capture', async () => {
|
||||
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
|
||||
const { manager } = makeSceneManager(1, camera)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { RendererView } from '@/renderer/three/RendererView'
|
||||
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import {
|
||||
type BackgroundRenderModeType,
|
||||
@@ -38,16 +40,18 @@ export class SceneManager implements SceneManagerInterface {
|
||||
|
||||
private eventManager: EventManagerInterface
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private view: RendererView
|
||||
|
||||
private getActiveCamera: () => THREE.Camera
|
||||
|
||||
constructor(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
view: RendererView,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
_getControls: () => OrbitControls,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.renderer = renderer
|
||||
this.view = view
|
||||
this.renderer = view.renderer
|
||||
this.eventManager = eventManager
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
@@ -65,7 +69,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
// forceRender directly into onDirty caused a per-frame render-setDirty
|
||||
// cascade that made splats visibly "balloon" during camera interaction.
|
||||
this.sparkRenderer = new SparkRenderer({
|
||||
renderer,
|
||||
renderer: view.renderer,
|
||||
onDirty: () => {
|
||||
const resolve = this.nextSparkDirtyResolve
|
||||
this.nextSparkDirtyResolve = null
|
||||
@@ -104,7 +108,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.backgroundMesh.position.set(0, 0, 0)
|
||||
this.backgroundScene.add(this.backgroundMesh)
|
||||
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.view.state.clearColor.set(0x000000)
|
||||
this.view.state.clearAlpha = 0
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -250,8 +255,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
this.renderer.domElement.clientWidth,
|
||||
this.renderer.domElement.clientHeight
|
||||
this.view.canvas.clientWidth,
|
||||
this.view.canvas.clientHeight
|
||||
)
|
||||
}
|
||||
|
||||
@@ -369,6 +374,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<{ scene: string; mask: string; normal: string }> {
|
||||
this.view.beginRender()
|
||||
|
||||
const originalSize = new THREE.Vector2()
|
||||
this.renderer.getSize(originalSize)
|
||||
const originalPixelRatio = this.renderer.getPixelRatio()
|
||||
@@ -495,7 +502,10 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.renderer.setPixelRatio(originalPixelRatio)
|
||||
this.renderer.setSize(originalSize.x, originalSize.y)
|
||||
this.renderer.outputColorSpace = originalOutputColorSpace
|
||||
this.handleResize(originalSize.x, originalSize.y)
|
||||
this.handleResize(
|
||||
this.view.canvas.clientWidth,
|
||||
this.view.canvas.clientHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,13 @@ import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
function createMockRenderer(): THREE.WebGLRenderer {
|
||||
return {
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
dispose: vi.fn()
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
function createMockEventManager(): EventManagerInterface {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
@@ -30,7 +25,7 @@ function createManager(
|
||||
} = {}
|
||||
) {
|
||||
const scene = overrides.scene ?? new THREE.Scene()
|
||||
const renderer = createMockRenderer()
|
||||
const viewState = createRendererViewState()
|
||||
const eventManager = overrides.eventManager ?? createMockEventManager()
|
||||
const camera = new THREE.PerspectiveCamera()
|
||||
const getActiveCamera = () => camera
|
||||
@@ -43,7 +38,7 @@ function createManager(
|
||||
|
||||
const manager = new SceneModelManager(
|
||||
scene,
|
||||
renderer,
|
||||
viewState,
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
setupCamera,
|
||||
@@ -54,7 +49,7 @@ function createManager(
|
||||
return {
|
||||
manager,
|
||||
scene,
|
||||
renderer,
|
||||
viewState,
|
||||
eventManager,
|
||||
camera,
|
||||
setupCamera,
|
||||
@@ -67,7 +62,6 @@ function createManagerWithPose(opts: {
|
||||
pose: { size: THREE.Vector3; center: THREE.Vector3 } | null
|
||||
}) {
|
||||
const scene = new THREE.Scene()
|
||||
const renderer = createMockRenderer()
|
||||
const eventManager = createMockEventManager()
|
||||
const camera = new THREE.PerspectiveCamera()
|
||||
const setupCamera = vi.fn()
|
||||
@@ -79,7 +73,7 @@ function createManagerWithPose(opts: {
|
||||
|
||||
const manager = new SceneModelManager(
|
||||
scene,
|
||||
renderer,
|
||||
createRendererViewState(),
|
||||
eventManager,
|
||||
() => camera,
|
||||
setupCamera,
|
||||
@@ -474,7 +468,7 @@ describe('SceneModelManager', () => {
|
||||
})
|
||||
|
||||
it('switches to depth material', async () => {
|
||||
const { manager, renderer } = createManager()
|
||||
const { manager, viewState } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
@@ -482,7 +476,7 @@ describe('SceneModelManager', () => {
|
||||
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
|
||||
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
})
|
||||
|
||||
it('restores original material when switching back', async () => {
|
||||
@@ -513,16 +507,16 @@ describe('SceneModelManager', () => {
|
||||
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
|
||||
})
|
||||
|
||||
it('sets renderer color space to SRGB for non-depth modes', async () => {
|
||||
const { manager, renderer } = createManager()
|
||||
it('sets view color space to SRGB for non-depth modes', async () => {
|
||||
const { manager, viewState } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setMaterialMode('depth')
|
||||
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
|
||||
manager.setMaterialMode('normal')
|
||||
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
|
||||
expect(viewState.outputColorSpace).toBe(THREE.SRGBColorSpace)
|
||||
})
|
||||
|
||||
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { SparkRenderer } from '@sparkjsdev/spark'
|
||||
import * as THREE from 'three'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
|
||||
|
||||
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { ModelAdapterCapabilities } from './ModelAdapter'
|
||||
import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter'
|
||||
@@ -29,6 +31,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
standardMaterial: THREE.MeshStandardMaterial
|
||||
wireframeMaterial: THREE.MeshBasicMaterial
|
||||
depthMaterial: THREE.MeshDepthMaterial
|
||||
clayMaterial: THREE.MeshStandardMaterial
|
||||
originalFileName: string | null = null
|
||||
originalURL: string | null = null
|
||||
appliedTexture: THREE.Texture | null = null
|
||||
@@ -37,7 +40,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
showSkeleton: boolean = false
|
||||
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private viewState: RendererViewState
|
||||
private eventManager: EventManagerInterface
|
||||
private activeCamera: THREE.Camera
|
||||
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
|
||||
@@ -52,7 +55,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
viewState: RendererViewState,
|
||||
eventManager: EventManagerInterface,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
|
||||
@@ -68,7 +71,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
} | null = () => null
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.viewState = viewState
|
||||
this.eventManager = eventManager
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.setupCamera = setupCamera
|
||||
@@ -98,8 +101,44 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
depthPacking: THREE.BasicDepthPacking,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
this.depthMaterial.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.cameraType = {
|
||||
value: this.activeCamera instanceof THREE.OrthographicCamera ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
shader.fragmentShader = `
|
||||
uniform float cameraType;
|
||||
${shader.fragmentShader}
|
||||
`
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
|
||||
`
|
||||
float depth = 1.0 - fragCoordZ;
|
||||
if (cameraType > 0.5) {
|
||||
depth = pow(depth, 400.0);
|
||||
} else {
|
||||
depth = pow(depth, 0.6);
|
||||
}
|
||||
gl_FragColor = vec4(vec3(depth), opacity);
|
||||
`
|
||||
)
|
||||
}
|
||||
this.depthMaterial.customProgramCacheKey = () => {
|
||||
return this.activeCamera instanceof THREE.OrthographicCamera
|
||||
? 'ortho'
|
||||
: 'persp'
|
||||
}
|
||||
|
||||
this.standardMaterial = this.createSTLMaterial()
|
||||
|
||||
this.clayMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x888888,
|
||||
metalness: 0.0,
|
||||
roughness: 0.9,
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -110,6 +149,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.standardMaterial.dispose()
|
||||
this.wireframeMaterial.dispose()
|
||||
this.depthMaterial.dispose()
|
||||
this.clayMaterial.dispose()
|
||||
|
||||
if (this.appliedTexture) {
|
||||
this.appliedTexture.dispose()
|
||||
@@ -196,9 +236,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
if (mode === 'depth') {
|
||||
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
this.viewState.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
} else {
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.viewState.outputColorSpace = THREE.SRGBColorSpace
|
||||
}
|
||||
|
||||
if (this.currentModel) {
|
||||
@@ -212,68 +252,25 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
if (!this.originalMaterials.has(child)) {
|
||||
this.originalMaterials.set(child, child.material)
|
||||
}
|
||||
const depthMat = new THREE.MeshDepthMaterial({
|
||||
depthPacking: THREE.BasicDepthPacking,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
depthMat.onBeforeCompile = (shader) => {
|
||||
shader.uniforms.cameraType = {
|
||||
value:
|
||||
this.activeCamera instanceof THREE.OrthographicCamera
|
||||
? 1.0
|
||||
: 0.0
|
||||
}
|
||||
|
||||
shader.fragmentShader = `
|
||||
uniform float cameraType;
|
||||
${shader.fragmentShader}
|
||||
`
|
||||
|
||||
shader.fragmentShader = shader.fragmentShader.replace(
|
||||
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
|
||||
`
|
||||
float depth = 1.0 - fragCoordZ;
|
||||
if (cameraType > 0.5) {
|
||||
depth = pow(depth, 400.0);
|
||||
} else {
|
||||
depth = pow(depth, 0.6);
|
||||
}
|
||||
gl_FragColor = vec4(vec3(depth), opacity);
|
||||
`
|
||||
)
|
||||
}
|
||||
|
||||
depthMat.customProgramCacheKey = () => {
|
||||
return this.activeCamera instanceof THREE.OrthographicCamera
|
||||
? 'ortho'
|
||||
: 'persp'
|
||||
}
|
||||
|
||||
child.material = depthMat
|
||||
child.material = this.depthMaterial
|
||||
break
|
||||
case 'normal':
|
||||
if (!this.originalMaterials.has(child)) {
|
||||
this.originalMaterials.set(child, child.material)
|
||||
}
|
||||
child.material = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide,
|
||||
normalScale: new THREE.Vector2(1, 1),
|
||||
transparent: false,
|
||||
opacity: 1.0
|
||||
})
|
||||
child.material = this.normalMaterial
|
||||
break
|
||||
case 'wireframe':
|
||||
if (!this.originalMaterials.has(child)) {
|
||||
this.originalMaterials.set(child, child.material)
|
||||
}
|
||||
child.material = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
wireframe: true,
|
||||
transparent: false,
|
||||
opacity: 1.0
|
||||
})
|
||||
child.material = this.wireframeMaterial
|
||||
break
|
||||
case 'clay':
|
||||
if (!this.originalMaterials.has(child)) {
|
||||
this.originalMaterials.set(child, child.material)
|
||||
}
|
||||
child.material = this.clayMaterial
|
||||
break
|
||||
case 'original':
|
||||
case 'pointCloud':
|
||||
|
||||
@@ -15,6 +15,16 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private readonly helperCamera = new THREE.OrthographicCamera(
|
||||
-2,
|
||||
2,
|
||||
2,
|
||||
-2,
|
||||
0,
|
||||
4
|
||||
)
|
||||
private readonly savedViewport = new THREE.Vector4()
|
||||
|
||||
constructor(
|
||||
_renderer: THREE.WebGLRenderer,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
@@ -24,10 +34,25 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
|
||||
this.getActiveCamera = getActiveCamera
|
||||
this.getControls = getControls
|
||||
this.eventManager = eventManager
|
||||
this.helperCamera.position.set(0, 0, 2)
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
|
||||
render(renderer: THREE.WebGLRenderer, size: number): void {
|
||||
const helper = this.viewHelper
|
||||
if (!helper.isViewHelper) return
|
||||
|
||||
helper.quaternion.copy(this.getActiveCamera().quaternion).invert()
|
||||
helper.updateMatrixWorld()
|
||||
|
||||
renderer.clearDepth()
|
||||
renderer.getViewport(this.savedViewport)
|
||||
renderer.setViewport(0, 0, size, size)
|
||||
renderer.render(helper, this.helperCamera)
|
||||
renderer.setViewport(this.savedViewport)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.viewHelper) {
|
||||
this.viewHelper.dispose()
|
||||
|
||||
@@ -394,14 +394,10 @@ describe('Viewport3d', () => {
|
||||
initialRenderTimer: null,
|
||||
startAnimation: vi.fn(),
|
||||
renderLoop: { stop: vi.fn() },
|
||||
resizeObserver: null,
|
||||
disposeContextMenuGuard: null,
|
||||
renderer: {
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
domElement: Object.assign(document.createElement('canvas'), {
|
||||
remove: vi.fn()
|
||||
})
|
||||
view: {
|
||||
canvas: document.createElement('canvas'),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
disposeManagers: vi.fn()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
|
||||
import type { RendererView } from '@/renderer/three/RendererView'
|
||||
|
||||
import type { CameraManager } from './CameraManager'
|
||||
import type { ControlsManager } from './ControlsManager'
|
||||
@@ -19,8 +19,10 @@ import type { RenderLoopHandle } from './load3dRenderLoop'
|
||||
import { startRenderLoop } from './load3dRenderLoop'
|
||||
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
|
||||
|
||||
const VIEW_HELPER_SIZE = 128
|
||||
|
||||
export type Viewport3dDeps = {
|
||||
renderer: THREE.WebGLRenderer
|
||||
view: RendererView
|
||||
eventManager: EventManager
|
||||
sceneManager: SceneManager
|
||||
cameraManager: CameraManager
|
||||
@@ -29,7 +31,8 @@ export type Viewport3dDeps = {
|
||||
viewHelperManager: ViewHelperManager
|
||||
}
|
||||
|
||||
export class Viewport3d extends WebGLViewport {
|
||||
export class Viewport3d {
|
||||
protected readonly view: RendererView
|
||||
protected clock: THREE.Clock
|
||||
private renderLoop: RenderLoopHandle | null = null
|
||||
private onContextMenuCallback?: (event: MouseEvent) => void
|
||||
@@ -57,13 +60,14 @@ export class Viewport3d extends WebGLViewport {
|
||||
private externalActiveCamera: THREE.Camera | null = null
|
||||
private overlay: SceneOverlay | null = null
|
||||
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private viewPixelScale = 1
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
container: HTMLElement,
|
||||
deps: Viewport3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
super(deps.renderer)
|
||||
this.view = deps.view
|
||||
this.clock = new THREE.Clock()
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
@@ -94,7 +98,15 @@ export class Viewport3d extends WebGLViewport {
|
||||
this.STATUS_MOUSE_ON_VIEWER = false
|
||||
|
||||
this.initContextMenu()
|
||||
this.observeResize(container, () => this.handleResize())
|
||||
this.view.observeResize(container, () => this.handleResize())
|
||||
}
|
||||
|
||||
get renderer(): THREE.WebGLRenderer {
|
||||
return this.view.renderer
|
||||
}
|
||||
|
||||
get domElement(): HTMLCanvasElement {
|
||||
return this.view.canvas
|
||||
}
|
||||
|
||||
start(): void {
|
||||
@@ -120,7 +132,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
|
||||
private initContextMenu(): void {
|
||||
this.disposeContextMenuGuard = attachContextMenuGuard(
|
||||
this.renderer.domElement,
|
||||
this.view.canvas,
|
||||
(event) => this.onContextMenuCallback?.(event),
|
||||
{ isDisabled: () => this.isViewerMode }
|
||||
)
|
||||
@@ -159,17 +171,23 @@ export class Viewport3d extends WebGLViewport {
|
||||
forceRender(): void {
|
||||
const delta = this.clock.getDelta()
|
||||
this.tickPerFrame(delta)
|
||||
|
||||
this.renderMainScene()
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
|
||||
this.renderView()
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
}
|
||||
|
||||
private renderView(): void {
|
||||
this.view.beginRender()
|
||||
this.renderMainScene()
|
||||
|
||||
this.renderer.setScissorTest(false)
|
||||
this.viewHelperManager.render(
|
||||
this.renderer,
|
||||
VIEW_HELPER_SIZE * this.viewPixelScale
|
||||
)
|
||||
|
||||
this.view.blit()
|
||||
}
|
||||
|
||||
protected tickPerFrame(delta: number): void {
|
||||
this.overlay?.update?.(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
@@ -219,8 +237,8 @@ export class Viewport3d extends WebGLViewport {
|
||||
}
|
||||
|
||||
renderMainScene(): void {
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
const viewWidth = this.view.width
|
||||
const viewHeight = this.view.height
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
@@ -229,15 +247,16 @@ export class Viewport3d extends WebGLViewport {
|
||||
}
|
||||
}
|
||||
|
||||
this.renderer.setViewport(0, 0, viewWidth, viewHeight)
|
||||
this.renderer.setScissor(0, 0, viewWidth, viewHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
{ width: viewWidth, height: viewHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
this.renderer.setClearColor(0x0a0a0a)
|
||||
this.renderer.clear()
|
||||
|
||||
@@ -246,34 +265,23 @@ export class Viewport3d extends WebGLViewport {
|
||||
|
||||
this.cameraManager.updateAspectRatio(width / height)
|
||||
} else {
|
||||
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
|
||||
this.renderer.setScissorTest(true)
|
||||
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())
|
||||
}
|
||||
|
||||
resetViewport(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
protected startAnimation(): void {
|
||||
this.renderLoop = startRenderLoop({
|
||||
tick: () => {
|
||||
const delta = this.clock.getDelta()
|
||||
this.tickPerFrame(delta)
|
||||
this.renderMainScene()
|
||||
this.resetViewport()
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
this.renderView()
|
||||
},
|
||||
isActive: () => this.isActive()
|
||||
})
|
||||
@@ -346,7 +354,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
}
|
||||
|
||||
handleResize(): void {
|
||||
const parentElement = this.renderer?.domElement?.parentElement
|
||||
const parentElement = this.view.canvas.parentElement
|
||||
|
||||
if (!parentElement) {
|
||||
console.warn('Parent element not found')
|
||||
@@ -357,7 +365,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
const containerHeight = parentElement.clientHeight
|
||||
|
||||
const zoomScale = this.getZoomScaleCallback?.() ?? 1
|
||||
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
|
||||
this.viewPixelScale = Math.min(zoomScale, 3)
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
@@ -366,17 +374,20 @@ export class Viewport3d extends WebGLViewport {
|
||||
}
|
||||
}
|
||||
|
||||
this.view.setSize(
|
||||
containerWidth * this.viewPixelScale,
|
||||
containerHeight * this.viewPixelScale
|
||||
)
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
{ width: containerWidth, height: containerHeight },
|
||||
this.targetAspectRatio
|
||||
)
|
||||
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(width, height)
|
||||
this.sceneManager.handleResize(width, height)
|
||||
} else {
|
||||
this.renderer.setSize(containerWidth, containerHeight)
|
||||
this.cameraManager.handleResize(containerWidth, containerHeight)
|
||||
this.sceneManager.handleResize(containerWidth, containerHeight)
|
||||
}
|
||||
@@ -398,7 +409,7 @@ export class Viewport3d extends WebGLViewport {
|
||||
|
||||
this.disposeManagers()
|
||||
|
||||
this.disposeRenderer()
|
||||
this.view.dispose()
|
||||
}
|
||||
|
||||
protected disposeManagers(): void {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
|
||||
import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter'
|
||||
@@ -20,7 +20,14 @@ vi.mock('three', async () => {
|
||||
rendererCtor(opts)
|
||||
}
|
||||
setSize() {}
|
||||
setPixelRatio() {}
|
||||
setClearColor() {}
|
||||
getSize(target: { set(x: number, y: number): unknown }) {
|
||||
target.set(300, 300)
|
||||
return target
|
||||
}
|
||||
forceContextLoss() {}
|
||||
dispose() {}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -163,6 +170,12 @@ function makeAdapter(overrides: Partial<ModelAdapter> = {}): ModelAdapter {
|
||||
}
|
||||
|
||||
describe('createLoad3d', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage: vi.fn()
|
||||
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
|
||||
})
|
||||
|
||||
it('constructs the renderer with alpha + antialias and appends it to the container', () => {
|
||||
rendererCtor.mockClear()
|
||||
const container = createContainer()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import * as THREE from 'three'
|
||||
import type * as THREE from 'three'
|
||||
|
||||
import { RendererView } from '@/renderer/three/RendererView'
|
||||
|
||||
import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
@@ -17,25 +19,9 @@ import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import type { Load3DOptions } from './interfaces'
|
||||
|
||||
function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
renderer.setSize(300, 300)
|
||||
renderer.setClearColor(0x282828)
|
||||
renderer.autoClear = false
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
renderer.domElement.classList.add(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'outline-none'
|
||||
)
|
||||
container.appendChild(renderer.domElement)
|
||||
return renderer
|
||||
}
|
||||
|
||||
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
const renderer = createRenderer(container)
|
||||
function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
|
||||
const view = new RendererView(container)
|
||||
const renderer = view.renderer
|
||||
const eventManager = new EventManager()
|
||||
// Shared mutable handle: LoaderManager writes the active adapter on each
|
||||
// load; SceneModelManager reads it for capability/bounds/dispose lookups
|
||||
@@ -50,7 +36,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
const getControls = () => controlsManager.controls
|
||||
|
||||
const sceneManager = new SceneManager(
|
||||
renderer,
|
||||
view,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
@@ -58,7 +44,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
|
||||
cameraManager = new CameraManager(renderer, eventManager)
|
||||
controlsManager = new ControlsManager(
|
||||
renderer,
|
||||
container,
|
||||
cameraManager.activeCamera,
|
||||
eventManager
|
||||
)
|
||||
@@ -68,6 +54,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
const hdriManager = new HDRIManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
view.state,
|
||||
eventManager
|
||||
)
|
||||
const viewHelperManager = new ViewHelperManager(
|
||||
@@ -79,7 +66,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
|
||||
const modelManager = new SceneModelManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
view.state,
|
||||
eventManager,
|
||||
getActiveCamera,
|
||||
(size, center) => cameraManager.setupForModel(size, center),
|
||||
@@ -98,14 +85,14 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
)
|
||||
const recordingManager = new RecordingManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
view.canvas,
|
||||
eventManager
|
||||
)
|
||||
const animationManager = new AnimationManager(eventManager)
|
||||
|
||||
gizmoManager = new GizmoManager(
|
||||
sceneManager.scene,
|
||||
renderer,
|
||||
container,
|
||||
controlsManager.controls,
|
||||
getActiveCamera,
|
||||
() => {
|
||||
@@ -119,7 +106,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
)
|
||||
|
||||
return {
|
||||
renderer,
|
||||
view,
|
||||
eventManager,
|
||||
sceneManager,
|
||||
cameraManager,
|
||||
@@ -137,7 +124,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
|
||||
}
|
||||
|
||||
export function createLoad3d(
|
||||
container: Element | HTMLElement,
|
||||
container: HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Load3d {
|
||||
return new Load3d(container, buildLoad3dDeps(container), options)
|
||||
|
||||
@@ -11,6 +11,7 @@ export type MaterialMode =
|
||||
| 'normal'
|
||||
| 'wireframe'
|
||||
| 'depth'
|
||||
| 'clay'
|
||||
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
|
||||
export type CameraType = 'perspective' | 'orthographic'
|
||||
export type BackgroundRenderModeType = 'tiled' | 'panorama'
|
||||
|
||||
@@ -2068,6 +2068,7 @@
|
||||
"centerCameraOnModel": "Center Camera on Model",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"model3d": "3D Model",
|
||||
"camera": "Camera",
|
||||
"light": "Light",
|
||||
"switchingMaterialMode": "Switching Material Mode...",
|
||||
@@ -2078,13 +2079,35 @@
|
||||
"reloadingModel": "Reloading model...",
|
||||
"uploadTexture": "Upload Texture",
|
||||
"applyingTexture": "Applying Texture...",
|
||||
"menuBar": {
|
||||
"showGrid": "Show grid",
|
||||
"bgColor": "BG Color",
|
||||
"bgImage": "BG Image",
|
||||
"panorama": "Panorama",
|
||||
"removeBackground": "Remove BG",
|
||||
"upDirection": "Up Direction",
|
||||
"material": "Material",
|
||||
"skeleton": "Skeleton",
|
||||
"fov": "FOV",
|
||||
"intensity": "Intensity",
|
||||
"record": "Record",
|
||||
"recording": "Recording",
|
||||
"stopRecording": "Stop recording",
|
||||
"downloadRecording": "Download Recording",
|
||||
"startNewRecording": "Start New Recording",
|
||||
"deleteRecording": "Delete Recording",
|
||||
"videoRecordingTooltip": "Video recording of the scene [mp4]",
|
||||
"switchProjection": "Switch projection",
|
||||
"originalMaterialOnly": "Original material only"
|
||||
},
|
||||
"materialModes": {
|
||||
"normal": "Normal",
|
||||
"wireframe": "Wireframe",
|
||||
"original": "Original",
|
||||
"pointCloud": "Point Cloud",
|
||||
"depth": "Depth",
|
||||
"lineart": "Lineart"
|
||||
"lineart": "Lineart",
|
||||
"clay": "Clay"
|
||||
},
|
||||
"upDirections": {
|
||||
"original": "Original"
|
||||
@@ -2116,10 +2139,10 @@
|
||||
"uploadingModel": "Uploading 3D model...",
|
||||
"loadingHDRI": "Loading HDRI...",
|
||||
"hdri": {
|
||||
"label": "HDRI Environment",
|
||||
"uploadFile": "Upload HDRI (.hdr, .exr)",
|
||||
"changeFile": "Change HDRI",
|
||||
"removeFile": "Remove HDRI",
|
||||
"label": "HDRI",
|
||||
"uploadFile": "Upload",
|
||||
"changeFile": "Change",
|
||||
"removeFile": "Remove",
|
||||
"showAsBackground": "Show as Background",
|
||||
"intensity": "Intensity"
|
||||
},
|
||||
@@ -2129,7 +2152,7 @@
|
||||
"translate": "Translate",
|
||||
"rotate": "Rotate",
|
||||
"scale": "Scale",
|
||||
"reset": "Reset Transform"
|
||||
"reset": "Reset"
|
||||
}
|
||||
},
|
||||
"imageCrop": {
|
||||
|
||||
@@ -135,25 +135,22 @@ describe('FormDropdownMenu', () => {
|
||||
})
|
||||
|
||||
/** Regression: PrimeVue Popover teleports the menu to document.body, so
|
||||
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
|
||||
* itself rather than relying on the LGraphNode wheel handler. */
|
||||
* trackpad pinch-zoom must be guarded on the menu itself — `LGraphNode`
|
||||
* never sees these events. macOS pinch synthesizes `ctrlKey`; explicit
|
||||
* `⌘ + wheel` (and Windows/Linux equivalents) come through as `metaKey`. */
|
||||
it.for([
|
||||
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
|
||||
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
|
||||
])('suppresses browser default for $name', ({ overrides }) => {
|
||||
{ name: 'ctrlKey', modifier: 'ctrlKey' as const },
|
||||
{ name: 'metaKey', modifier: 'metaKey' as const }
|
||||
])('suppresses browser default for pinch-zoom ($name)', ({ modifier }) => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
Object.defineProperty(event, key, { value })
|
||||
})
|
||||
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, modifier, { value: true })
|
||||
Object.defineProperty(event, 'deltaY', { value: -10 })
|
||||
root.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
@@ -211,19 +208,33 @@ describe('FormDropdownMenu', () => {
|
||||
expect(emitted('show-picker')).toHaveLength(1)
|
||||
})
|
||||
|
||||
/** Vertical scrolling must remain native so the dropdown's own scroll
|
||||
* container can scroll its content. */
|
||||
it('does not suppress vertical scroll', () => {
|
||||
/** Regression: slow trackpad vertical scrolls emit small-delta frames with
|
||||
* stray horizontal jitter (`|deltaX| > |deltaY|`). preventDefault-ing those
|
||||
* cancelled the native scroll and starved the VirtualGrid's `useScroll`,
|
||||
* leaving rows blank. Horizontal-swipe-to-navigate is blocked at the page
|
||||
* boundary by `overscroll-behavior: none` on `html, body`, so the local
|
||||
* handler must NOT preventDefault these events. The `pure vertical` case
|
||||
* also covers plain native scrolling of the dropdown's own content. */
|
||||
it.for([
|
||||
{ name: 'pure vertical', overrides: { deltaY: 30 } },
|
||||
{
|
||||
name: 'slow scroll with horizontal jitter',
|
||||
overrides: { deltaX: 1.5, deltaY: 1.2 }
|
||||
},
|
||||
{
|
||||
name: 'pronounced horizontal swipe',
|
||||
overrides: { deltaX: 30, deltaY: 5 }
|
||||
}
|
||||
])('does not suppress $name wheel', ({ overrides }) => {
|
||||
render(FormDropdownMenu, {
|
||||
props: defaultProps,
|
||||
global: globalConfig
|
||||
})
|
||||
|
||||
const root = screen.getByTestId('form-dropdown-menu')
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: 30,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true })
|
||||
Object.entries(overrides).forEach(([key, value]) => {
|
||||
Object.defineProperty(event, key, { value })
|
||||
})
|
||||
root.dispatchEvent(event)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import { isCanvasGestureWheel } from '@/base/wheelGestures'
|
||||
|
||||
import type {
|
||||
FilterOption,
|
||||
@@ -109,12 +108,17 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
|
||||
/**
|
||||
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
|
||||
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
|
||||
* Suppress only the destructive browser defaults (page zoom on pinch and
|
||||
* back/forward on horizontal swipe); regular vertical scrolling still
|
||||
* scrolls the dropdown's own content.
|
||||
* Pinch-zoom (`ctrl/meta + wheel`) would otherwise trigger page-level zoom and
|
||||
* push fixed UI off-screen, so it must be suppressed locally. Horizontal
|
||||
* swipe-to-navigate is already blocked at the page boundary by
|
||||
* `overscroll-behavior: none` on `html, body`, so we deliberately do NOT
|
||||
* preventDefault on horizontal-dominant wheels here — doing so cancels the
|
||||
* trackpad's native momentum on slow vertical scrolls (where stray horizontal
|
||||
* jitter frames satisfy `|deltaX| > |deltaY|`), starving the VirtualGrid's
|
||||
* `useScroll` and leaving rows blank until a fast scroll re-pumps samples.
|
||||
*/
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (isCanvasGestureWheel(event)) event.preventDefault()
|
||||
if (event.ctrlKey || event.metaKey) event.preventDefault()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
126
src/renderer/three/RendererView.test.ts
Normal file
126
src/renderer/three/RendererView.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RendererView } from './RendererView'
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof THREE>()
|
||||
class WebGLRenderer {
|
||||
domElement = document.createElement('canvas')
|
||||
autoClear = true
|
||||
outputColorSpace = ''
|
||||
toneMapping = 0
|
||||
toneMappingExposure = 1
|
||||
setSize(width: number, height: number) {
|
||||
this.domElement.width = width
|
||||
this.domElement.height = height
|
||||
}
|
||||
getSize(target: { set(x: number, y: number): unknown }) {
|
||||
target.set(this.domElement.width, this.domElement.height)
|
||||
return target
|
||||
}
|
||||
setPixelRatio() {}
|
||||
setClearColor = vi.fn()
|
||||
forceContextLoss = vi.fn()
|
||||
dispose = vi.fn()
|
||||
}
|
||||
return { ...actual, WebGLRenderer }
|
||||
})
|
||||
|
||||
const drawImage = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
drawImage.mockClear()
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
drawImage,
|
||||
globalCompositeOperation: 'source-over'
|
||||
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
|
||||
})
|
||||
|
||||
describe('RendererView', () => {
|
||||
it('appends its own canvas to the container', () => {
|
||||
const container = document.createElement('div')
|
||||
|
||||
const view = new RendererView(container)
|
||||
|
||||
expect(view.canvas.parentElement).toBe(container)
|
||||
view.dispose()
|
||||
expect(view.canvas.parentElement).toBeNull()
|
||||
})
|
||||
|
||||
it('setSize resizes the view canvas and grows the shared buffer to fit', () => {
|
||||
const container = document.createElement('div')
|
||||
const view = new RendererView(container)
|
||||
|
||||
view.setSize(640.4, 480.6)
|
||||
|
||||
expect(view.width).toBe(640)
|
||||
expect(view.height).toBe(481)
|
||||
expect(view.canvas.width).toBe(640)
|
||||
expect(view.canvas.height).toBe(481)
|
||||
expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(640)
|
||||
expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(481)
|
||||
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
it('setSize clamps degenerate sizes to 1 pixel', () => {
|
||||
const container = document.createElement('div')
|
||||
const view = new RendererView(container)
|
||||
|
||||
view.setSize(0, -5)
|
||||
|
||||
expect(view.width).toBe(1)
|
||||
expect(view.height).toBe(1)
|
||||
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
it('beginRender applies the view state and regrows a shrunken shared buffer', () => {
|
||||
const container = document.createElement('div')
|
||||
const view = new RendererView(container)
|
||||
view.setSize(400, 300)
|
||||
view.state.toneMapping = THREE.ACESFilmicToneMapping
|
||||
view.state.toneMappingExposure = 0.5
|
||||
view.state.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
view.state.clearAlpha = 0.25
|
||||
view.renderer.setSize(50, 40)
|
||||
|
||||
view.beginRender()
|
||||
|
||||
expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(400)
|
||||
expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(300)
|
||||
expect(view.renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping)
|
||||
expect(view.renderer.toneMappingExposure).toBe(0.5)
|
||||
expect(view.renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
expect(view.renderer.setClearColor).toHaveBeenCalledWith(
|
||||
view.state.clearColor,
|
||||
0.25
|
||||
)
|
||||
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
it('blit copies the bottom-left region of the shared buffer into the view canvas', () => {
|
||||
const container = document.createElement('div')
|
||||
const view = new RendererView(container)
|
||||
view.renderer.setSize(1000, 800)
|
||||
view.setSize(400, 300)
|
||||
|
||||
view.blit()
|
||||
|
||||
expect(drawImage).toHaveBeenCalledWith(
|
||||
view.renderer.domElement,
|
||||
0,
|
||||
500,
|
||||
400,
|
||||
300,
|
||||
0,
|
||||
0,
|
||||
400,
|
||||
300
|
||||
)
|
||||
|
||||
view.dispose()
|
||||
})
|
||||
})
|
||||
88
src/renderer/three/RendererView.ts
Normal file
88
src/renderer/three/RendererView.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type * as THREE from 'three'
|
||||
|
||||
import type {
|
||||
RendererViewState,
|
||||
SharedRendererHandle
|
||||
} from './sharedWebGLRenderer'
|
||||
import {
|
||||
acquireSharedRenderer,
|
||||
applyRendererViewState,
|
||||
createRendererViewState,
|
||||
ensureRendererSize
|
||||
} from './sharedWebGLRenderer'
|
||||
|
||||
export class RendererView {
|
||||
readonly renderer: THREE.WebGLRenderer
|
||||
readonly canvas: HTMLCanvasElement
|
||||
readonly state: RendererViewState = createRendererViewState()
|
||||
|
||||
width = 1
|
||||
height = 1
|
||||
|
||||
private readonly context: CanvasRenderingContext2D
|
||||
private readonly handle: SharedRendererHandle
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.classList.add(
|
||||
'absolute',
|
||||
'inset-0',
|
||||
'h-full',
|
||||
'w-full',
|
||||
'outline-none'
|
||||
)
|
||||
const context = this.canvas.getContext('2d')
|
||||
if (!context) {
|
||||
throw new Error('Failed to create 2D context for 3D view')
|
||||
}
|
||||
this.context = context
|
||||
|
||||
this.handle = acquireSharedRenderer()
|
||||
this.renderer = this.handle.renderer
|
||||
container.appendChild(this.canvas)
|
||||
}
|
||||
|
||||
setSize(width: number, height: number): void {
|
||||
this.width = Math.max(1, Math.round(width))
|
||||
this.height = Math.max(1, Math.round(height))
|
||||
if (this.canvas.width !== this.width) this.canvas.width = this.width
|
||||
if (this.canvas.height !== this.height) this.canvas.height = this.height
|
||||
ensureRendererSize(this.renderer, this.width, this.height)
|
||||
}
|
||||
|
||||
beginRender(): void {
|
||||
ensureRendererSize(this.renderer, this.width, this.height)
|
||||
applyRendererViewState(this.renderer, this.state)
|
||||
}
|
||||
|
||||
blit(): void {
|
||||
const source = this.renderer.domElement
|
||||
this.context.globalCompositeOperation = 'copy'
|
||||
this.context.drawImage(
|
||||
source,
|
||||
0,
|
||||
source.height - this.height,
|
||||
this.width,
|
||||
this.height,
|
||||
0,
|
||||
0,
|
||||
this.width,
|
||||
this.height
|
||||
)
|
||||
}
|
||||
|
||||
observeResize(target: Element, onResize: () => void): void {
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = new ResizeObserver(() => onResize())
|
||||
this.resizeObserver.observe(target)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.resizeObserver?.disconnect()
|
||||
this.resizeObserver = null
|
||||
this.canvas.remove()
|
||||
this.handle.release()
|
||||
}
|
||||
}
|
||||
140
src/renderer/three/sharedWebGLRenderer.test.ts
Normal file
140
src/renderer/three/sharedWebGLRenderer.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
acquireSharedRenderer,
|
||||
applyRendererViewState,
|
||||
createRendererViewState,
|
||||
ensureRendererSize
|
||||
} from './sharedWebGLRenderer'
|
||||
|
||||
const { rendererCtor, forceContextLoss, dispose } = vi.hoisted(() => ({
|
||||
rendererCtor: vi.fn(),
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof THREE>()
|
||||
class WebGLRenderer {
|
||||
domElement = document.createElement('canvas')
|
||||
autoClear = true
|
||||
outputColorSpace = ''
|
||||
toneMapping = 0
|
||||
toneMappingExposure = 1
|
||||
width = 0
|
||||
height = 0
|
||||
constructor(opts: unknown) {
|
||||
rendererCtor(opts)
|
||||
}
|
||||
setSize(width: number, height: number) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
getSize(target: THREE.Vector2) {
|
||||
target.set(this.width, this.height)
|
||||
return target
|
||||
}
|
||||
setPixelRatio() {}
|
||||
setClearColor = vi.fn()
|
||||
forceContextLoss = forceContextLoss
|
||||
dispose = dispose
|
||||
}
|
||||
return { ...actual, WebGLRenderer }
|
||||
})
|
||||
|
||||
describe('acquireSharedRenderer', () => {
|
||||
it('returns the same renderer for concurrent views and disposes after the last release', () => {
|
||||
rendererCtor.mockClear()
|
||||
forceContextLoss.mockClear()
|
||||
dispose.mockClear()
|
||||
|
||||
const first = acquireSharedRenderer()
|
||||
const second = acquireSharedRenderer()
|
||||
|
||||
expect(second.renderer).toBe(first.renderer)
|
||||
expect(rendererCtor).toHaveBeenCalledTimes(1)
|
||||
|
||||
first.release()
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
|
||||
const disposedWhenContextLostFired: boolean[] = []
|
||||
second.renderer.domElement.addEventListener('webglcontextlost', () => {
|
||||
disposedWhenContextLostFired.push(dispose.mock.calls.length > 0)
|
||||
})
|
||||
|
||||
second.release()
|
||||
expect(forceContextLoss).toHaveBeenCalledTimes(1)
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
expect(disposedWhenContextLostFired).toEqual([false])
|
||||
})
|
||||
|
||||
it('creates a fresh renderer after the previous one was torn down', () => {
|
||||
rendererCtor.mockClear()
|
||||
|
||||
const first = acquireSharedRenderer()
|
||||
first.release()
|
||||
|
||||
const second = acquireSharedRenderer()
|
||||
|
||||
expect(rendererCtor).toHaveBeenCalledTimes(2)
|
||||
expect(second.renderer).not.toBe(first.renderer)
|
||||
second.release()
|
||||
})
|
||||
|
||||
it('ignores double release from the same handle', () => {
|
||||
dispose.mockClear()
|
||||
|
||||
const first = acquireSharedRenderer()
|
||||
const second = acquireSharedRenderer()
|
||||
|
||||
first.release()
|
||||
first.release()
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled()
|
||||
second.release()
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureRendererSize', () => {
|
||||
it('grows the drawing buffer per-axis but never shrinks it', () => {
|
||||
const handle = acquireSharedRenderer()
|
||||
const renderer = handle.renderer
|
||||
renderer.setSize(300, 300)
|
||||
|
||||
ensureRendererSize(renderer, 500, 200)
|
||||
expect(renderer.getSize(new THREE.Vector2())).toEqual(
|
||||
new THREE.Vector2(500, 300)
|
||||
)
|
||||
|
||||
ensureRendererSize(renderer, 400, 250)
|
||||
expect(renderer.getSize(new THREE.Vector2())).toEqual(
|
||||
new THREE.Vector2(500, 300)
|
||||
)
|
||||
|
||||
handle.release()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRendererViewState', () => {
|
||||
it('writes the per-view state onto the renderer', () => {
|
||||
const handle = acquireSharedRenderer()
|
||||
const renderer = handle.renderer
|
||||
const state = createRendererViewState()
|
||||
state.toneMapping = THREE.ACESFilmicToneMapping
|
||||
state.toneMappingExposure = 0.5
|
||||
state.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
state.clearColor.set(0x123456)
|
||||
state.clearAlpha = 0.25
|
||||
|
||||
applyRendererViewState(renderer, state)
|
||||
|
||||
expect(renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping)
|
||||
expect(renderer.toneMappingExposure).toBe(0.5)
|
||||
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
|
||||
expect(renderer.setClearColor).toHaveBeenCalledWith(state.clearColor, 0.25)
|
||||
|
||||
handle.release()
|
||||
})
|
||||
})
|
||||
78
src/renderer/three/sharedWebGLRenderer.ts
Normal file
78
src/renderer/three/sharedWebGLRenderer.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
export type RendererViewState = {
|
||||
toneMapping: THREE.ToneMapping
|
||||
toneMappingExposure: number
|
||||
outputColorSpace: THREE.ColorSpace
|
||||
clearColor: THREE.Color
|
||||
clearAlpha: number
|
||||
}
|
||||
|
||||
export function createRendererViewState(): RendererViewState {
|
||||
return {
|
||||
toneMapping: THREE.NoToneMapping,
|
||||
toneMappingExposure: 1.0,
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
clearColor: new THREE.Color(0x000000),
|
||||
clearAlpha: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRendererViewState(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
state: RendererViewState
|
||||
): void {
|
||||
renderer.toneMapping = state.toneMapping
|
||||
renderer.toneMappingExposure = state.toneMappingExposure
|
||||
renderer.outputColorSpace = state.outputColorSpace
|
||||
renderer.setClearColor(state.clearColor, state.clearAlpha)
|
||||
}
|
||||
|
||||
export type SharedRendererHandle = {
|
||||
renderer: THREE.WebGLRenderer
|
||||
release: () => void
|
||||
}
|
||||
|
||||
let sharedRenderer: THREE.WebGLRenderer | null = null
|
||||
let viewCount = 0
|
||||
|
||||
function createSharedRenderer(): THREE.WebGLRenderer {
|
||||
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
|
||||
renderer.setPixelRatio(1)
|
||||
renderer.setSize(300, 300)
|
||||
renderer.autoClear = false
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
return renderer
|
||||
}
|
||||
|
||||
export function acquireSharedRenderer(): SharedRendererHandle {
|
||||
sharedRenderer ??= createSharedRenderer()
|
||||
viewCount++
|
||||
const renderer = sharedRenderer
|
||||
let released = false
|
||||
return {
|
||||
renderer,
|
||||
release() {
|
||||
if (released) return
|
||||
released = true
|
||||
viewCount--
|
||||
if (viewCount > 0 || sharedRenderer !== renderer) return
|
||||
sharedRenderer = null
|
||||
renderer.forceContextLoss()
|
||||
renderer.domElement.dispatchEvent(
|
||||
new Event('webglcontextlost', { bubbles: true, cancelable: true })
|
||||
)
|
||||
renderer.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRendererSize(
|
||||
renderer: THREE.WebGLRenderer,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
const size = renderer.getSize(new THREE.Vector2())
|
||||
if (size.width >= width && size.height >= height) return
|
||||
renderer.setSize(Math.max(size.width, width), Math.max(size.height, height))
|
||||
}
|
||||
Reference in New Issue
Block a user