Skip to content

Commit 5ab0757

Browse files
committed
feat(camera-info): 3D viewport widget for CreateCameraInfo node
1 parent 1c396f6 commit 5ab0757

45 files changed

Lines changed: 5390 additions & 24 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { render, screen } from '@testing-library/vue'
2+
import userEvent from '@testing-library/user-event'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { nextTick } from 'vue'
5+
import { createI18n } from 'vue-i18n'
6+
7+
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
8+
9+
const i18n = createI18n({
10+
legacy: false,
11+
locale: 'en',
12+
messages: {
13+
en: {
14+
load3d: {
15+
showGizmos: 'Show gizmos',
16+
hideGizmos: 'Hide gizmos',
17+
lookThrough: 'Camera view',
18+
exitLookThrough: 'Exit camera view',
19+
transformGizmo: {
20+
none: 'None',
21+
target: 'Target',
22+
cameraTranslate: 'Cam pos',
23+
cameraRotate: 'Cam rot'
24+
}
25+
}
26+
}
27+
}
28+
})
29+
30+
type ApiMocks = Record<string, ReturnType<typeof vi.fn>>
31+
32+
const holder = vi.hoisted(() => ({
33+
modeRef: null as { value: string } | null,
34+
api: null as ApiMocks | null
35+
}))
36+
37+
vi.mock('@/composables/useCameraInfo', async () => {
38+
const { ref } = await import('vue')
39+
const modeRef = ref('orbit')
40+
const api = {
41+
initialize: vi.fn(),
42+
cleanup: vi.fn(),
43+
handleMouseEnter: vi.fn(),
44+
handleMouseLeave: vi.fn(),
45+
setGizmosVisible: vi.fn(),
46+
setTransformGizmoMode: vi.fn(),
47+
setLookThrough: vi.fn()
48+
}
49+
holder.modeRef = modeRef
50+
holder.api = api
51+
return { useCameraInfo: () => ({ ...api, mode: modeRef }) }
52+
})
53+
54+
vi.mock('@vueuse/core', async (importOriginal) => {
55+
const actual = await importOriginal<Record<string, unknown>>()
56+
const { ref } = await import('vue')
57+
return {
58+
...actual,
59+
useElementSize: () => ({ width: ref(600), height: ref(400) })
60+
}
61+
})
62+
63+
import CameraInfo from './CameraInfo.vue'
64+
65+
function makeWidget(): SimplifiedWidget {
66+
return {
67+
name: 'camera_info_state',
68+
type: 'cameraInfo',
69+
value: [],
70+
options: {}
71+
} as unknown as SimplifiedWidget
72+
}
73+
74+
function renderComponent() {
75+
return render(CameraInfo, {
76+
props: { widget: makeWidget() },
77+
global: {
78+
plugins: [i18n],
79+
directives: { tooltip: {} }
80+
}
81+
})
82+
}
83+
84+
function setMode(value: string) {
85+
holder.modeRef!.value = value
86+
}
87+
88+
function api(): ApiMocks {
89+
return holder.api!
90+
}
91+
92+
describe('CameraInfo toolbar', () => {
93+
beforeEach(() => {
94+
setMode('orbit')
95+
Object.values(api()).forEach((fn) => fn.mockClear())
96+
})
97+
98+
it('initializes the viewport on mount and cleans up on unmount', () => {
99+
const { unmount } = renderComponent()
100+
expect(api().initialize).toHaveBeenCalledOnce()
101+
102+
unmount()
103+
expect(api().cleanup).toHaveBeenCalledOnce()
104+
})
105+
106+
it('toggles gizmo visibility when the gizmos button is clicked', async () => {
107+
renderComponent()
108+
const user = userEvent.setup()
109+
110+
await user.click(screen.getByRole('button', { name: 'Hide gizmos' }))
111+
112+
expect(api().setGizmosVisible).toHaveBeenCalledWith(false)
113+
})
114+
115+
it('enters camera view when the camera-view button is clicked', async () => {
116+
renderComponent()
117+
const user = userEvent.setup()
118+
119+
await user.click(screen.getByRole('button', { name: 'Camera view' }))
120+
121+
expect(api().setLookThrough).toHaveBeenCalledWith(true)
122+
})
123+
})
124+
125+
describe('CameraInfo transform gizmo reconciliation', () => {
126+
beforeEach(() => {
127+
setMode('orbit')
128+
Object.values(api()).forEach((fn) => fn.mockClear())
129+
})
130+
131+
it('resets the selected gizmo to none when the new mode disables it', async () => {
132+
setMode('quaternion')
133+
renderComponent()
134+
const user = userEvent.setup()
135+
136+
await user.click(screen.getByRole('button', { name: 'Cam rot' }))
137+
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith(
138+
'camera-rotate'
139+
)
140+
141+
setMode('orbit')
142+
await nextTick()
143+
144+
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith('none')
145+
})
146+
147+
it('keeps the selected gizmo when the new mode still supports it', async () => {
148+
setMode('orbit')
149+
renderComponent()
150+
const user = userEvent.setup()
151+
152+
await user.click(screen.getByRole('button', { name: 'Target' }))
153+
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith('target')
154+
155+
setMode('look_at')
156+
await nextTick()
157+
158+
expect(api().setTransformGizmoMode).not.toHaveBeenCalledWith('none')
159+
})
160+
})
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<template>
2+
<div
3+
class="relative size-full min-h-[300px]"
4+
@pointerdown.stop
5+
@mousedown.stop
6+
>
7+
<div
8+
ref="container"
9+
class="relative size-full"
10+
data-capture-wheel="true"
11+
tabindex="-1"
12+
@pointerdown.stop="focusContainer"
13+
@contextmenu.stop.prevent
14+
@mouseenter="handleMouseEnter"
15+
@mouseleave="handleMouseLeave"
16+
/>
17+
<div class="pointer-events-none absolute inset-x-0 top-0">
18+
<div
19+
ref="toolbar"
20+
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
21+
@wheel.stop
22+
>
23+
<button
24+
v-tooltip.bottom="tip(gizmosLabel)"
25+
type="button"
26+
:disabled="lookingThrough"
27+
:class="
28+
cn(
29+
actionClass(!lookingThrough && gizmosOn),
30+
lookingThrough && 'cursor-not-allowed opacity-40'
31+
)
32+
"
33+
:aria-pressed="!lookingThrough && gizmosOn"
34+
:aria-label="compact ? gizmosLabel : undefined"
35+
@click="toggleGizmos"
36+
>
37+
<i
38+
:class="
39+
cn(
40+
'size-4',
41+
gizmosOn ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'
42+
)
43+
"
44+
/>
45+
<span v-if="!compact">{{ gizmosLabel }}</span>
46+
</button>
47+
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
48+
<button
49+
v-for="option in transformGizmoOptions"
50+
:key="option.value"
51+
v-tooltip.bottom="tip($t(option.labelKey))"
52+
type="button"
53+
:disabled="lookingThrough || !option.enabled"
54+
:aria-pressed="!lookingThrough && transformGizmoMode === option.value"
55+
:aria-label="compact ? $t(option.labelKey) : undefined"
56+
:class="
57+
cn(
58+
actionClass(
59+
!lookingThrough && transformGizmoMode === option.value
60+
),
61+
(lookingThrough || !option.enabled) &&
62+
'cursor-not-allowed opacity-40'
63+
)
64+
"
65+
@click="selectTransformGizmo(option.value)"
66+
>
67+
<i :class="cn('size-4', option.icon)" />
68+
<span v-if="!compact">{{ $t(option.labelKey) }}</span>
69+
</button>
70+
</div>
71+
</div>
72+
<div class="pointer-events-none absolute inset-x-0 bottom-0">
73+
<div
74+
class="pointer-events-auto flex h-10 items-center justify-end gap-1 bg-interface-menu-surface px-2"
75+
@wheel.stop
76+
>
77+
<button
78+
v-tooltip.top="tip(lookThroughLabel)"
79+
type="button"
80+
:class="
81+
cn(iconBtnClass, lookingThrough && 'bg-button-active-surface')
82+
"
83+
:aria-pressed="lookingThrough"
84+
:aria-label="lookThroughLabel"
85+
@click="toggleLookThrough"
86+
>
87+
<i class="icon-[lucide--video] size-4" />
88+
</button>
89+
</div>
90+
</div>
91+
</div>
92+
</template>
93+
94+
<script setup lang="ts">
95+
import { useElementSize } from '@vueuse/core'
96+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
97+
import type { Ref } from 'vue'
98+
import { useI18n } from 'vue-i18n'
99+
100+
import {
101+
actionClass,
102+
iconBtnClass,
103+
tip
104+
} from '@/components/load3d/menubar/menuBarStyles'
105+
import { useCameraInfo } from '@/composables/useCameraInfo'
106+
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
107+
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
108+
import type { ComponentWidget } from '@/scripts/domWidget'
109+
import type { NodeId } from '@/types/nodeId'
110+
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
111+
import { resolveNode } from '@/utils/litegraphUtil'
112+
import { cn } from '@comfyorg/tailwind-utils'
113+
114+
const { widget, nodeId } = defineProps<{
115+
widget: ComponentWidget<string[]> | SimplifiedWidget
116+
nodeId?: NodeId
117+
}>()
118+
119+
function isComponentWidget(
120+
w: ComponentWidget<string[]> | SimplifiedWidget
121+
): w is ComponentWidget<string[]> {
122+
return 'node' in w && w.node !== undefined
123+
}
124+
125+
const node = ref<LGraphNode | null>(null)
126+
if (isComponentWidget(widget)) {
127+
node.value = widget.node
128+
} else if (nodeId) {
129+
onMounted(() => {
130+
node.value = resolveNode(nodeId) ?? null
131+
})
132+
}
133+
134+
const { t } = useI18n()
135+
136+
const container = ref<HTMLElement | null>(null)
137+
const toolbar = ref<HTMLElement | null>(null)
138+
const { width: toolbarWidth } = useElementSize(toolbar)
139+
const compactWidthThreshold = 480
140+
const compact = computed(
141+
() => toolbarWidth.value > 0 && toolbarWidth.value < compactWidthThreshold
142+
)
143+
const gizmosOn = ref(true)
144+
const lookingThrough = ref(false)
145+
const transformGizmoMode = ref<TransformGizmoMode>('none')
146+
const {
147+
initialize,
148+
cleanup,
149+
handleMouseEnter,
150+
handleMouseLeave,
151+
setGizmosVisible,
152+
setTransformGizmoMode,
153+
setLookThrough,
154+
mode
155+
} = useCameraInfo(node as Ref<LGraphNode | null>)
156+
157+
const gizmosLabel = computed(() =>
158+
gizmosOn.value ? t('load3d.hideGizmos') : t('load3d.showGizmos')
159+
)
160+
161+
const lookThroughLabel = computed(() =>
162+
lookingThrough.value ? t('load3d.exitLookThrough') : t('load3d.lookThrough')
163+
)
164+
165+
const transformGizmoOptions = computed(() => [
166+
{
167+
value: 'none' as const,
168+
labelKey: 'load3d.transformGizmo.none',
169+
icon: 'icon-[lucide--ban]',
170+
enabled: true
171+
},
172+
{
173+
value: 'target' as const,
174+
labelKey: 'load3d.transformGizmo.target',
175+
icon: 'icon-[lucide--target]',
176+
enabled: mode.value === 'orbit' || mode.value === 'look_at'
177+
},
178+
{
179+
value: 'camera-translate' as const,
180+
labelKey: 'load3d.transformGizmo.cameraTranslate',
181+
icon: 'icon-[lucide--move-3d]',
182+
enabled: mode.value === 'look_at' || mode.value === 'quaternion'
183+
},
184+
{
185+
value: 'camera-rotate' as const,
186+
labelKey: 'load3d.transformGizmo.cameraRotate',
187+
icon: 'icon-[lucide--rotate-3d]',
188+
enabled: mode.value === 'quaternion'
189+
}
190+
])
191+
192+
function focusContainer() {
193+
container.value?.focus()
194+
}
195+
196+
function toggleGizmos() {
197+
gizmosOn.value = !gizmosOn.value
198+
}
199+
200+
function toggleLookThrough() {
201+
lookingThrough.value = !lookingThrough.value
202+
}
203+
204+
function selectTransformGizmo(value: TransformGizmoMode) {
205+
transformGizmoMode.value = value
206+
}
207+
208+
watch(gizmosOn, (on) => setGizmosVisible(on))
209+
watch(transformGizmoMode, (m) => setTransformGizmoMode(m))
210+
watch(lookingThrough, (on) => setLookThrough(on))
211+
watch(mode, () => {
212+
const selected = transformGizmoOptions.value.find(
213+
({ value }) => value === transformGizmoMode.value
214+
)
215+
if (!selected?.enabled) transformGizmoMode.value = 'none'
216+
})
217+
218+
onMounted(() => {
219+
if (container.value) initialize(container.value)
220+
})
221+
222+
onUnmounted(() => {
223+
cleanup()
224+
})
225+
</script>

src/components/load3d/Load3DControls.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272
v-if="showCameraControls"
7373
v-model:camera-type="cameraConfig!.cameraType"
7474
v-model:fov="cameraConfig!.fov"
75+
v-model:use-custom-up="cameraConfig!.useCustomUp"
76+
:has-custom-up="cameraConfig!.hasCustomUp ?? false"
7577
/>
7678

7779
<div v-if="showLightControls" class="flex flex-col">

0 commit comments

Comments
 (0)