mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 21:38:52 +00:00
> Final piece of the PLY / 3D Gaussian Splatting series. Previous PR made `ModelAdapterCapabilities` load-bearing on the engine side; the UI was still gating off `isSplatModel` / `isPlyModel` proxies. This PR routes the viewer and the viewer-mode dialog through the capability fields directly, so the same source of truth that drives `Load3d` behavior also drives what the user sees. Eighth and last in the series splitting up. ## Summary Snapshot `Load3d.getCurrentModelCapabilities()` into 5 Vue refs on each model load and pipe them through the existing `Load3D` / `Load3DControls` / `Load3dViewerContent` / `ModelControls` / `ViewerModelControls` / `Preview3d` props. Replaces the format-specific `:is-splat-model` / `:is-ply-model` props and the hardcoded "splat → drop light/gizmo/export" subtraction with additive capability gates. No engine behavior changes — capability values are what previous PR already produces; UI now consumes them. ## Changes - **`useLoad3d.ts` / `useLoad3dViewer.ts`**: 5 new refs (`canFitToViewer` / `canUseGizmo` / `canUseLighting` / `canExport` / `materialModes`) refreshed on every load via `load3d.getCurrentModelCapabilities()`. `useLoad3dViewer` extracts the snapshot into a single `captureAdapterFlags(source)` helper because it runs in three places (initializeViewer / initializeStandaloneViewer / loadStandaloneModel). - **`Load3D.vue`**: gate the fit-to-viewer button on `canFitToViewer`; pass capability refs to `Load3DControls` instead of `isSplatModel` / `isPlyModel`. - **`Load3DControls.vue`**: build `availableCategories` additively (`['scene','model','camera']` plus `light` / `gizmo` / `export` if their capability is true) rather than subtracting from a fixed list when `isSplatModel` is true. Forwards `materialModes` to `ModelControls`. - **`Load3dViewerContent.vue`**: gate the light / gizmo / export sidebar sections on the capability refs; pass `materialModes` to `ViewerModelControls`. - **`ModelControls.vue` / `ViewerModelControls.vue`**: drop the local `materialModes` computed (which derived its options from `isPlyModel` and a hardcoded mesh list) and accept `materialModes` as a `readonly MaterialMode[]` prop. An empty array hides the dropdown entirely. - **`Preview3d.vue`** (renderer linearMode): mirror the prop swap on the standalone preview path. ## Review Focus - **Capability prop wiring is the only public-API change for child components**. `ModelControls` and `ViewerModelControls` lost `hideMaterialMode` / `isPlyModel` props. Any extension that imported these components directly will need to migrate, but they're internal `src/components/load3d/controls/**` files and not part of the documented extension surface. - **Empty-`materialModes` semantics**: previously hidden via `:hide-material-mode`; now hidden via `materialModes.length === 0`. `SplatModelAdapter` declares `materialModes: []`, so the splat case keeps the same behavior — the dropdown disappears. PLY adds `'pointCloud'` to the array, so the dropdown picks up that mode automatically without the controls needing an `isPlyModel` branch. - **`captureAdapterFlags` runs after every load completes**, so switching between mesh and splat in the same viewer instance updates the chrome correctly. Verified via the new `Load3D.test.ts` / `Load3dViewerContent.test.ts` cases. - **Capability gating is inclusive of `canFitToViewer`** in this PR even though `Load3DControls` has no fit category — the fit-to-viewer floating button on `Load3D.vue` is what reads it. PLY's `fitToViewer: true` means the button stays visible for PLY users. ## Coverage | File | Stmts | Branch | Funcs | |---|---|---|---| | `Load3D.vue` (modified) | 53.3% | **95.5%** | 83.3% | | `Load3DControls.vue` (modified) | 77.5% | **94.8%** | 86.4% | | `Load3dViewerContent.vue` (modified) | 60.6% | 72.1% | 54.5% | | `controls/ModelControls.vue` (modified) | 16.3% | 0% | 0% | | `controls/viewer/ViewerModelControls.vue` (modified) | **100%** | **100%** | **100%** | | `composables/useLoad3d.ts` (modified) | 78.7% | 64.5% | 71.4% | | `composables/useLoad3dViewer.ts` (modified) | 76.0% | 52.1% | 66.7% | Four new test files (`Load3D.test.ts` / `Load3DControls.test.ts` / `Load3dViewerContent.test.ts` / `controls/viewer/ViewerModelControls.test.ts`) cover the new capability gating directly: each component is rendered with capability flags toggled on/off and the appropriate sidebar / dropdown / button visibility is asserted. Capability prop forwarding from `Load3D.vue` → `Load3DControls.vue` and from `Load3dViewerContent.vue` → `ViewerModelControls.vue` is exercised end-to-end. `controls/ModelControls.vue` is the legacy node-side ModelControls — its existing tests live elsewhere and were not in this PR's scope; the diff line covered (the `v-if="materialModes.length > 0"` swap) is exercised by the new `Load3DControls.test.ts` cases that drive a non-empty / empty `materialModes` through. `Preview3d.vue` (renderer linearMode) has no test file in the project; the prop swap there is the same shape as the `Load3D.vue` swap which is covered. `useLoad3d.ts` / `useLoad3dViewer.ts` percentages are roughly the pre-existing baseline. The diff lines (the 5 new refs and the `captureAdapterFlags` helper) are exercised by the existing composable tests via the mock that now stubs `getCurrentModelCapabilities()`. 73 new component unit tests; 393 total load3d-related tests pass on this branch. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11711-feat-load3d-bind-UI-capability-gating-to-ModelAdapterCapabilities-3506d73d365081b3af68f30e3f728e24) by [Unito](https://www.unito.io)
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
import { render, screen } from '@testing-library/vue'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { createI18n } from 'vue-i18n'
|
|
|
|
import Load3DControls from '@/components/load3d/Load3DControls.vue'
|
|
import type {
|
|
CameraConfig,
|
|
LightConfig,
|
|
MaterialMode,
|
|
ModelConfig,
|
|
SceneConfig
|
|
} from '@/extensions/core/load3d/interfaces'
|
|
|
|
vi.mock('@/composables/useDismissableOverlay', () => ({
|
|
useDismissableOverlay: vi.fn()
|
|
}))
|
|
|
|
const i18n = createI18n({
|
|
legacy: false,
|
|
locale: 'en',
|
|
messages: {
|
|
en: {
|
|
menu: { showMenu: 'Show menu' },
|
|
load3d: {
|
|
scene: 'Scene',
|
|
model: 'Model',
|
|
camera: 'Camera',
|
|
light: 'Light',
|
|
gizmo: { label: 'Gizmo' },
|
|
export: 'Export'
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
const childStubs = {
|
|
SceneControls: {
|
|
name: 'SceneControls',
|
|
emits: ['update-background-image'],
|
|
template: `<div data-testid="scene-controls">
|
|
<button data-testid="scene-emit-bg" @click="$emit('update-background-image', null)" />
|
|
</div>`
|
|
},
|
|
ModelControls: {
|
|
name: 'ModelControls',
|
|
template: '<div data-testid="model-controls" />'
|
|
},
|
|
CameraControls: {
|
|
name: 'CameraControls',
|
|
template: '<div data-testid="camera-controls" />'
|
|
},
|
|
LightControls: {
|
|
name: 'LightControls',
|
|
template: '<div data-testid="light-controls" />'
|
|
},
|
|
HDRIControls: {
|
|
name: 'HDRIControls',
|
|
emits: ['update-hdri-file'],
|
|
template: `<div data-testid="hdri-controls">
|
|
<button data-testid="hdri-emit-file" @click="$emit('update-hdri-file', null)" />
|
|
</div>`
|
|
},
|
|
ExportControls: {
|
|
name: 'ExportControls',
|
|
emits: ['export-model'],
|
|
template: `<div data-testid="export-controls">
|
|
<button data-testid="export-emit-glb" @click="$emit('export-model', 'glb')" />
|
|
</div>`
|
|
},
|
|
GizmoControls: {
|
|
name: 'GizmoControls',
|
|
emits: ['toggle-gizmo', 'set-gizmo-mode', 'reset-gizmo-transform'],
|
|
template: `<div data-testid="gizmo-controls">
|
|
<button data-testid="gizmo-emit-toggle" @click="$emit('toggle-gizmo', true)" />
|
|
<button data-testid="gizmo-emit-mode" @click="$emit('set-gizmo-mode', 'rotate')" />
|
|
<button data-testid="gizmo-emit-reset" @click="$emit('reset-gizmo-transform')" />
|
|
</div>`
|
|
}
|
|
}
|
|
|
|
const defaultSceneConfig: SceneConfig = {
|
|
showGrid: true,
|
|
backgroundColor: '#000000',
|
|
backgroundImage: '',
|
|
backgroundRenderMode: 'tiled'
|
|
}
|
|
|
|
const defaultModelConfig: ModelConfig = {
|
|
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 }
|
|
}
|
|
}
|
|
|
|
const defaultCameraConfig: CameraConfig = {
|
|
cameraType: 'perspective',
|
|
fov: 75
|
|
}
|
|
|
|
const defaultLightConfig: LightConfig = {
|
|
intensity: 5,
|
|
hdri: {
|
|
enabled: false,
|
|
hdriPath: '',
|
|
showAsBackground: false,
|
|
intensity: 1
|
|
}
|
|
}
|
|
|
|
type RenderProps = {
|
|
sceneConfig?: SceneConfig
|
|
modelConfig?: ModelConfig
|
|
cameraConfig?: CameraConfig
|
|
lightConfig?: LightConfig
|
|
canUseGizmo?: boolean
|
|
canUseLighting?: boolean
|
|
canExport?: boolean
|
|
materialModes?: readonly MaterialMode[]
|
|
hasSkeleton?: boolean
|
|
onUpdateBackgroundImage?: (file: File | null) => void
|
|
onExportModel?: (format: string) => void
|
|
onUpdateHdriFile?: (file: File | null) => void
|
|
onToggleGizmo?: (enabled: boolean) => void
|
|
onSetGizmoMode?: (mode: string) => void
|
|
onResetGizmoTransform?: () => void
|
|
}
|
|
|
|
function renderControls(overrides: RenderProps = {}) {
|
|
const result = render(Load3DControls, {
|
|
props: {
|
|
sceneConfig: defaultSceneConfig,
|
|
modelConfig: defaultModelConfig,
|
|
cameraConfig: defaultCameraConfig,
|
|
lightConfig: defaultLightConfig,
|
|
canUseGizmo: true,
|
|
canUseLighting: true,
|
|
canExport: true,
|
|
materialModes: ['original', 'normal', 'wireframe'],
|
|
hasSkeleton: false,
|
|
...overrides
|
|
},
|
|
global: {
|
|
plugins: [i18n],
|
|
stubs: childStubs,
|
|
directives: {
|
|
tooltip: () => {}
|
|
}
|
|
}
|
|
})
|
|
return { ...result, user: userEvent.setup() }
|
|
}
|
|
|
|
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
|
await user.click(screen.getByRole('button', { name: 'Show menu' }))
|
|
}
|
|
|
|
describe('Load3DControls', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('category menu', () => {
|
|
it('renders SceneControls by default', () => {
|
|
renderControls()
|
|
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps the category menu closed until the trigger is clicked', async () => {
|
|
const { user } = renderControls()
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Scene' })
|
|
).not.toBeInTheDocument()
|
|
|
|
await openMenu(user)
|
|
|
|
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows every category when all capabilities are enabled', async () => {
|
|
const { user } = renderControls()
|
|
await openMenu(user)
|
|
|
|
for (const label of [
|
|
'Scene',
|
|
'Model',
|
|
'Camera',
|
|
'Light',
|
|
'Gizmo',
|
|
'Export'
|
|
]) {
|
|
expect(screen.getByRole('button', { name: label })).toBeInTheDocument()
|
|
}
|
|
})
|
|
|
|
it('omits the light category when canUseLighting is false', async () => {
|
|
const { user } = renderControls({ canUseLighting: false })
|
|
await openMenu(user)
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Light' })
|
|
).not.toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Scene' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('omits the gizmo category when canUseGizmo is false', async () => {
|
|
const { user } = renderControls({ canUseGizmo: false })
|
|
await openMenu(user)
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Gizmo' })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('omits the export category when canExport is false', async () => {
|
|
const { user } = renderControls({ canExport: false })
|
|
await openMenu(user)
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Export' })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('selecting a category closes the menu and swaps the visible control', async () => {
|
|
const { user } = renderControls()
|
|
await openMenu(user)
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Model' }))
|
|
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Scene' })
|
|
).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('model-controls')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('control visibility', () => {
|
|
async function selectCategory(
|
|
user: ReturnType<typeof userEvent.setup>,
|
|
label: string
|
|
) {
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: label }))
|
|
}
|
|
|
|
it.each([
|
|
['Model', 'model-controls'],
|
|
['Camera', 'camera-controls']
|
|
])('%s category renders only %s', async (label, testId) => {
|
|
const { user } = renderControls()
|
|
await selectCategory(user, label)
|
|
|
|
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
|
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('Light category renders both LightControls and HDRIControls', async () => {
|
|
const { user } = renderControls()
|
|
await selectCategory(user, 'Light')
|
|
|
|
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
|
expect(screen.getByTestId('hdri-controls')).toBeInTheDocument()
|
|
})
|
|
|
|
it('Gizmo category renders GizmoControls', async () => {
|
|
const { user } = renderControls()
|
|
await selectCategory(user, 'Gizmo')
|
|
|
|
expect(screen.getByTestId('gizmo-controls')).toBeInTheDocument()
|
|
})
|
|
|
|
it('Export category renders ExportControls', async () => {
|
|
const { user } = renderControls()
|
|
await selectCategory(user, 'Export')
|
|
|
|
expect(screen.getByTestId('export-controls')).toBeInTheDocument()
|
|
})
|
|
|
|
it('hides all controls when the corresponding v-model is undefined', () => {
|
|
renderControls({
|
|
sceneConfig: undefined,
|
|
modelConfig: undefined,
|
|
cameraConfig: undefined,
|
|
lightConfig: undefined
|
|
})
|
|
|
|
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('capability desync handling', () => {
|
|
it('hides the active panel and resets to scene when its capability is dropped at runtime', async () => {
|
|
const { user, rerender } = renderControls()
|
|
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: 'Light' }))
|
|
expect(screen.getByTestId('light-controls')).toBeInTheDocument()
|
|
|
|
await rerender({ canUseLighting: false })
|
|
|
|
expect(screen.queryByTestId('light-controls')).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
|
|
|
await openMenu(user)
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Light' })
|
|
).not.toBeInTheDocument()
|
|
})
|
|
|
|
it.each([
|
|
['Gizmo', 'gizmo-controls', 'canUseGizmo' as const],
|
|
['Export', 'export-controls', 'canExport' as const]
|
|
])(
|
|
'hides the %s panel when its capability flips off at runtime',
|
|
async (label, testId, capabilityProp) => {
|
|
const { user, rerender } = renderControls()
|
|
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: label }))
|
|
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
|
|
|
await rerender({ [capabilityProp]: false })
|
|
|
|
expect(screen.queryByTestId(testId)).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('scene-controls')).toBeInTheDocument()
|
|
}
|
|
)
|
|
|
|
it('does not reset activeCategory when capabilities change but the active one is still available', async () => {
|
|
const { user, rerender } = renderControls()
|
|
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: 'Camera' }))
|
|
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
|
|
|
|
await rerender({ canUseLighting: false, canUseGizmo: false })
|
|
|
|
expect(screen.getByTestId('camera-controls')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('scene-controls')).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
describe('event forwarding', () => {
|
|
it('forwards updateBackgroundImage from SceneControls', async () => {
|
|
const onUpdateBackgroundImage = vi.fn()
|
|
const { user } = renderControls({ onUpdateBackgroundImage })
|
|
|
|
await user.click(screen.getByTestId('scene-emit-bg'))
|
|
|
|
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('forwards exportModel from ExportControls', async () => {
|
|
const onExportModel = vi.fn()
|
|
const { user } = renderControls({ onExportModel })
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: 'Export' }))
|
|
|
|
await user.click(screen.getByTestId('export-emit-glb'))
|
|
|
|
expect(onExportModel).toHaveBeenCalledWith('glb')
|
|
})
|
|
|
|
it('forwards updateHdriFile from HDRIControls', async () => {
|
|
const onUpdateHdriFile = vi.fn()
|
|
const { user } = renderControls({ onUpdateHdriFile })
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: 'Light' }))
|
|
|
|
await user.click(screen.getByTestId('hdri-emit-file'))
|
|
|
|
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
|
})
|
|
|
|
it('forwards gizmo events from GizmoControls', async () => {
|
|
const onToggleGizmo = vi.fn()
|
|
const onSetGizmoMode = vi.fn()
|
|
const onResetGizmoTransform = vi.fn()
|
|
const { user } = renderControls({
|
|
onToggleGizmo,
|
|
onSetGizmoMode,
|
|
onResetGizmoTransform
|
|
})
|
|
await openMenu(user)
|
|
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
|
|
|
await user.click(screen.getByTestId('gizmo-emit-toggle'))
|
|
await user.click(screen.getByTestId('gizmo-emit-mode'))
|
|
await user.click(screen.getByTestId('gizmo-emit-reset'))
|
|
|
|
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
|
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
|
expect(onResetGizmoTransform).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
})
|