Compare commits

...

2 Commits

Author SHA1 Message Date
Terry Jia
909e776bd9 refactor(load3d): split menu bar into per-responsibility group components 2026-06-30 23:27:08 -04:00
PabloWiedemann
78e49d9360 feat(load3d): prototype top-bar chrome for embedded 3D viewer
Replace the floating viewer controls with a framed chrome: a black top bar
holding a category dropdown (Scene/3D Model/Camera/Lighting) plus the active
category's actions (labels collapse to icons on narrow nodes), and a black
bottom bar with Record and fit/export. Move export out of the menu into a
bottom-right button.

Add a 'Clay' material mode that renders meshes with a flat grey material so
geometry is visible without textures.

Proof-of-concept for design exploration; not intended to merge as-is.
2026-06-29 20:24:10 -04:00
30 changed files with 2058 additions and 459 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -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()
})
})

View File

@@ -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>)

View File

@@ -0,0 +1,239 @@
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('shows export/clear and forwards exportRecording once a recording exists', async () => {
const onExportRecording = vi.fn()
const { user } = renderMenuBar({
hasRecording: true,
isRecording: false,
onExportRecording
})
await user.click(screen.getByRole('button', { name: 'Export 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()
})
})

View File

@@ -0,0 +1,329 @@
<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">
{{ 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="flex-1" />
<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>

View File

@@ -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()
})
})

View File

@@ -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>

View 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()
})
})

View 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>

View 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')
})
})

View 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>

View 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)
})
})

View 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>

View 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')
})
})

View 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>

View 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)
})
})

View 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>

View File

@@ -0,0 +1,78 @@
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 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() }
}
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('exposes export, clear and duration once a recording exists', async () => {
const onExportRecording = vi.fn()
const onClearRecording = vi.fn()
const { user } = renderControl({
isRecording: false,
hasRecording: true,
recordingDuration: 65,
onExportRecording,
onClearRecording
})
expect(screen.getByText('01:05')).toBeInTheDocument()
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('hides export and clear while recording is in progress', () => {
renderControl({ isRecording: true, hasRecording: true })
expect(
screen.queryByRole('button', { name: 'Export Recording' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,88 @@
<template>
<button
v-tooltip.top="tip(recordLabel)"
:class="chipClass"
type="button"
:aria-label="compact ? recordLabel : undefined"
@click="toggleRecording"
>
<span
v-if="isRecording"
class="size-2 animate-pulse rounded-full bg-red-500"
/>
<i v-else class="icon-[lucide--video] size-4" />
<span v-if="!compact">{{ recordLabel }}</span>
</button>
<template v-if="hasRecording && !isRecording">
<button
v-tooltip.top="tip(t('load3d.exportRecording'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.exportRecording')"
@click="emit('exportRecording')"
>
<i class="icon-[lucide--download] size-4" />
</button>
<button
v-tooltip.top="tip(t('load3d.clearRecording'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.clearRecording')"
@click="emit('clearRecording')"
>
<i class="icon-[lucide--trash-2] size-4" />
</button>
<span
v-if="recordingDuration && recordingDuration > 0"
class="px-1 text-sm text-base-foreground"
>
{{ formatDuration(recordingDuration) }}
</span>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
chipClass,
iconBtnClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
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 recordLabel = computed(() =>
isRecording.value
? t('load3d.menuBar.stopRecording')
: t('load3d.menuBar.record')
)
function toggleRecording() {
if (isRecording.value) emit('stopRecording')
else emit('startRecording')
}
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>

View 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('')
})
})

View 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>

View 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 }
}

View File

@@ -177,6 +177,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -108,6 +108,7 @@ describe('MeshModelAdapter', () => {
expect(adapter.capabilities.exportable).toBe(true)
expect([...adapter.capabilities.materialModes]).toEqual([
'original',
'clay',
'normal',
'wireframe'
])

View File

@@ -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
}

View File

@@ -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'
])

View File

@@ -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
}

View File

@@ -29,6 +29,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
@@ -98,8 +99,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 +147,7 @@ export class SceneModelManager implements ModelManagerInterface {
this.standardMaterial.dispose()
this.wireframeMaterial.dispose()
this.depthMaterial.dispose()
this.clayMaterial.dispose()
if (this.appliedTexture) {
this.appliedTexture.dispose()
@@ -212,68 +250,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':

View File

@@ -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'

View File

@@ -2061,6 +2061,7 @@
"centerCameraOnModel": "Center Camera on Model",
"scene": "Scene",
"model": "Model",
"model3d": "3D Model",
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
@@ -2071,13 +2072,30 @@
"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",
"stopRecording": "Stop recording",
"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"
@@ -2109,10 +2127,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"
},
@@ -2122,7 +2140,7 @@
"translate": "Translate",
"rotate": "Rotate",
"scale": "Scale",
"reset": "Reset Transform"
"reset": "Reset"
}
},
"imageCrop": {