Compare commits

...

4 Commits

Author SHA1 Message Date
Michael B
29c3488c14 fix(fonts): fix PP Formula vertical metrics via Transfonter
Rebuilt all PP Formula woff2 files with "Fix vertical metrics" enabled to
normalize OS/2 ascender/descender values across browsers. Removes the need
for the commented-out CSS ascent-override workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 13:04:15 -04:00
Rizumu Ayaka
4972e4d42f fix(form-dropdown): stop cancelling native scroll on slow trackpad wheels (#12334)
## Summary

Follow-up to #12052. The local wheel guard added in FormDropdownMenu was
cancelling native scroll on slow macOS trackpad gestures, leaving image
rows blank in the asset picker until a fast scroll re-pumped samples
through.

## Changes

- **What**: `FormDropdownMenu.onWheel` now only `preventDefault`s on
pinch-zoom (`ctrl/meta + wheel`). The `|deltaX| > |deltaY|` branch of
`isCanvasGestureWheel` is no longer used here.
- **Why**: Slow vertical trackpad scrolls emit small-delta frames with
stray horizontal jitter (e.g. `deltaY=1.2`, `deltaX=1.5`) that satisfied
the horizontal-dominant predicate. Cancelling those frames starved
`VirtualGrid`'s throttled `useScroll`, so `Math.floor(scrollY /
itemHeight)` never advanced and tiles never mounted.
- Horizontal-swipe-to-navigate is still blocked at the page boundary by
`overscroll-behavior: none` on `html, body` (the actual fix from
#12052), so dropping the local horizontal guard does not reintroduce the
back/forward bug.
- The global `isCanvasGestureWheel` is unchanged —
`useCanvasInteractions` still uses it for the canvas wheel handler,
where the semantics are different (forward to canvas pan, not
preventDefault native scroll).

## Review Focus

- Confirm `overscroll-behavior: none` on `html, body` is sufficient on
its own for the horizontal-swipe-to-navigate case inside the teleported
dropdown. (It is — overscroll-behavior is decided per scroll container
at gesture start, independent of where in the page the gesture
originates.)
- Pinch-zoom in the dropdown still preventDefaults correctly.

Tested on macOS trackpad: slow vertical scroll now loads images
progressively; fast scroll unchanged; pinch-zoom in dropdown does not
zoom the page; horizontal swipe in dropdown does not navigate.

Related: this fixes the slow-scroll repro reported in #bug-dump. The
blank-on-reopen class of bug from FE-535 (#11885) is a different issue.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12334-fix-form-dropdown-stop-cancelling-native-scroll-on-slow-trackpad-wheels-3656d73d3650813a987ffa8d6f0eaa9c)
by [Unito](https://www.unito.io)
2026-07-13 16:08:29 +00:00
Terry Jia
8d8ae7dfc0 feat: share one WebGL context across all 3D views (#13547)
## Summary
Browsers cap live WebGL contexts per page, so each Load3D node owning
its own renderer broke once enough 3D nodes were added. All 3D views now
share a single offscreen WebGLRenderer: each view renders into the
bottom-left region of the shared drawing buffer and blits the frame into
its own plain 2D canvas, which has no context limit.

- Add sharedWebGLRenderer (refcounted singleton, grow-only buffer) and
RendererView (per-view canvas + per-view renderer state + blit)
- Per-view renderer state (tone mapping, color space, clear color) is
applied before each view renders; HDRIManager and SceneModelManager
write to it instead of the renderer
- OrbitControls/TransformControls bind to the node container explicitly
- ViewHelperManager renders the axes helper itself so it can position
and scale independently of renderer DOM layout
- RecordingManager records from the view canvas; capture renders at
exact size on the shared buffer as before
- On last view release the context is force-lost and webglcontextlost is
dispatched synchronously so context-loss handlers still run

## Screenshots (if applicable)
before
<img width="2155" height="1527" alt="image"
src="https://github.com/user-attachments/assets/35a7ab8a-28dd-41ba-8349-525bd2e97977"
/>

after
<img width="918" height="430" alt="屏幕截图 2026-07-09 135025"
src="https://github.com/user-attachments/assets/67328577-bd82-4494-921a-6d298442d081"
/>
<img width="1636" height="1509" alt="image"
src="https://github.com/user-attachments/assets/a3be9215-7cd0-427c-8264-1fd964d3ace1"
/>
2026-07-13 09:50:30 -04:00
Pablo
38c034e1d6 feat(load3d): prototype top-bar chrome for embedded 3D viewer (#13205)
## Summary

Prototype redesign of the embedded 3D viewer controls into a framed
top/bottom-bar chrome. Proof-of-concept for design exploration — not
intended to merge as-is.

## Changes

- **What**: Replaces the floating viewer controls (`Load3DControls`)
with a new `Load3DMenuBar` framed chrome: a black top bar with a
category dropdown (Scene / 3D Model / Camera / Lighting) and the active
category's actions (labels collapse to icons on narrow nodes), plus a
black bottom bar with Record (left) and fit + export (right). Export is
moved out of the menu into the bottom-right button. Adds a "Clay"
material mode that renders meshes with a flat grey material so geometry
is visible without textures.
- **Breaking**: None — `Load3D.vue` swaps the component and hides its
existing right-side button column behind `v-if="false"`; nothing is
deleted.

## Review Focus

- Design prototype: `Load3D.vue` swaps `Load3DControls` →
`Load3DMenuBar` and hides the existing fit/center/expand/record column.
The original components are left untouched.
- The only changes outside the prototype component are for the **Clay**
material mode, which touches shared engine code: `MaterialMode` type
(`interfaces.ts`), the runtime material switch (`SceneModelManager.ts`),
the capability lists (`MeshModelAdapter.ts`, `ModelAdapter.ts`,
`useLoad3d.ts`), and the i18n label.
- Known prototype gaps: **Record** is a visual toggle only (pulsing dot,
not wired to real recording); **fit** reuses the existing
`handleFitToViewer`; **export** reuses the existing format options.

## Screenshots (if applicable)

Prototype walkthrough captured during development (top-bar chrome,
category dropdown, labeled-vs-icon collapse, Clay material, pulsing
Record). Available on request.

---------

Co-authored-by: PabloWiedemann <PabloWiedemann@users.noreply.github.com>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
2026-07-12 23:09:54 -04:00
67 changed files with 3034 additions and 732 deletions

View File

@@ -255,9 +255,10 @@
Shifting visually with `top` corrects the appearance without changing the
inline-block's bounding box, so button/badge sizes are unaffected. */
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.1em;
/* display: inline-block; */
/* position: relative; */
/* top: 0.1em; */
--ppformula-text-center: 1;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -276,6 +276,7 @@ export const TestIds = {
overlay: 'loading-overlay'
},
load3d: {
categoryMenu: 'load3d-category-menu',
recordingDuration: 'load3d-recording-duration'
},
load3dViewer: {

View File

@@ -11,39 +11,47 @@ export class Load3DHelper {
}
get menuButton(): Locator {
return this.node.getByRole('button', { name: /show menu/i })
return this.node.getByTestId(TestIds.load3d.categoryMenu)
}
private get menuPanel(): Locator {
return this.node.page().getByRole('dialog')
}
get recordingButton(): Locator {
return this.node.getByRole('button', { name: /start recording/i })
return this.node.getByRole('button', { name: 'Record', exact: true })
}
get stopRecordingButton(): Locator {
return this.node.getByRole('button', { name: /stop recording/i })
}
get exportRecordingButton(): Locator {
return this.node.getByRole('button', { name: /export recording/i })
}
get clearRecordingButton(): Locator {
return this.node.getByRole('button', { name: /clear recording/i })
}
get recordingDuration(): Locator {
get recordingMenuButton(): Locator {
return this.node.getByTestId(TestIds.load3d.recordingDuration)
}
get downloadRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
}
get startNewRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
}
get deleteRecordingMenuItem(): Locator {
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
}
get gridToggleButton(): Locator {
return this.node.getByRole('button', { name: /show grid/i })
}
get uploadBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /upload background image/i })
return this.node.getByRole('button', { name: 'BG Image' })
}
get removeBackgroundImageButton(): Locator {
return this.node.getByRole('button', { name: /remove background image/i })
return this.node.getByRole('button', { name: 'Remove BG' })
}
get panoramaModeButton(): Locator {
@@ -54,6 +62,10 @@ export class Load3DHelper {
return this.node.locator('input[type="color"]')
}
get exportButton(): Locator {
return this.node.getByRole('button', { name: 'Export', exact: true })
}
get openViewerButton(): Locator {
return this.node.getByRole('button', { name: /open in 3d viewer/i })
}
@@ -63,11 +75,15 @@ export class Load3DHelper {
}
getMenuCategory(name: string): Locator {
return this.node.getByText(name, { exact: true })
return this.menuPanel.getByRole('button', { name, exact: true })
}
get gizmoToggleButton(): Locator {
return this.node.getByRole('button', { name: 'Gizmo' })
// The category chip is also named "Gizmo" once that category is active;
// only the toggle carries aria-pressed.
return this.node
.getByRole('button', { name: 'Gizmo' })
.and(this.node.locator('[aria-pressed]'))
}
get gizmoTranslateButton(): Locator {
@@ -83,13 +99,17 @@ export class Load3DHelper {
}
get gizmoResetButton(): Locator {
return this.node.getByRole('button', { name: 'Reset Transform' })
return this.node.getByRole('button', { name: 'Reset', exact: true })
}
async openMenu(): Promise<void> {
await this.menuButton.click()
}
async openRecordingMenu(): Promise<void> {
await this.recordingMenuButton.click()
}
async openGizmoCategory(): Promise<void> {
await this.openMenu()
await this.getMenuCategory('Gizmo').click()

View File

@@ -47,10 +47,12 @@ test.describe('Load3D', () => {
await load3d.openMenu()
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
await expect(load3d.getMenuCategory('Model')).toBeVisible()
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
await expect(load3d.getMenuCategory('Light')).toBeVisible()
await expect(load3d.getMenuCategory('Export')).toBeVisible()
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
await expect(load3d.exportButton).toBeVisible()
await expect(load3d.node).toHaveScreenshot(
'load3d-controls-menu-open.png',
@@ -253,7 +255,7 @@ test.describe('Load3D', () => {
}
)
test('Recording controls show stop/export/clear buttons after a recording', async ({
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
comfyPage,
load3d
}) => {
@@ -265,20 +267,25 @@ test.describe('Load3D', () => {
await expect(load3d.stopRecordingButton).toBeVisible()
})
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
// Record for 1s wall-clock so the duration display settles on 00:01.
await comfyPage.delay(1000)
await load3d.stopRecordingButton.click()
await expect(load3d.recordingButton).toBeVisible()
await expect(load3d.exportRecordingButton).toBeVisible()
await expect(load3d.clearRecordingButton).toBeVisible()
await expect(load3d.recordingDuration).toHaveText('00:01')
await expect(load3d.recordingMenuButton).toBeVisible()
await expect(load3d.recordingMenuButton).toHaveText('00:01')
})
await test.step('Clear recording removes export and clear controls', async () => {
await load3d.clearRecordingButton.click()
await expect(load3d.exportRecordingButton).toHaveCount(0)
await expect(load3d.clearRecordingButton).toHaveCount(0)
await test.step('Chip menu offers download, re-record and delete actions', async () => {
await load3d.openRecordingMenu()
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
})
await test.step('Deleting the recording restores the record button', async () => {
await load3d.deleteRecordingMenuItem.click()
await expect(load3d.recordingMenuButton).toHaveCount(0)
await expect(load3d.recordingButton).toBeVisible()
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 40 KiB

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,242 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
import type {
CameraConfig,
LightConfig,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function makeSceneConfig(): SceneConfig {
return {
showGrid: true,
backgroundColor: '#000000',
backgroundImage: '',
backgroundRenderMode: 'tiled'
}
}
function makeModelConfig(): ModelConfig {
return {
upDirection: 'original',
materialMode: 'original',
showSkeleton: false,
gizmo: {
enabled: false,
mode: 'translate',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 1, y: 1, z: 1 }
}
}
}
function makeCameraConfig(): CameraConfig {
return { cameraType: 'perspective', fov: 75 }
}
function makeLightConfig(): LightConfig {
return {
intensity: 5,
hdri: {
enabled: false,
hdriPath: '',
showAsBackground: false,
intensity: 1
}
}
}
type RenderProps = Partial<ComponentProps<typeof Load3DMenuBar>>
function renderMenuBar(overrides: RenderProps = {}) {
const result = render(Load3DMenuBar, {
props: {
sceneConfig: makeSceneConfig(),
modelConfig: makeModelConfig(),
cameraConfig: makeCameraConfig(),
lightConfig: makeLightConfig(),
...overrides
},
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
}
})
return { ...result, user: userEvent.setup() }
}
async function selectCategory(
user: ReturnType<typeof userEvent.setup>,
label: string
) {
await openCategoryMenu(user)
await user.click(screen.getByRole('button', { name: label }))
}
async function openCategoryMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: /Scene/ }))
}
describe('Load3DMenuBar', () => {
it('shows scene controls by default', () => {
renderMenuBar()
expect(
screen.getByRole('button', { name: 'Show grid' })
).toBeInTheDocument()
})
it('toggles showGrid on the bound config when the grid button is clicked', async () => {
const sceneConfig = makeSceneConfig()
const { user } = renderMenuBar({ sceneConfig })
await user.click(screen.getByRole('button', { name: 'Show grid' }))
expect(sceneConfig.showGrid).toBe(false)
})
it('emits fitToViewer when the fit button is clicked', async () => {
const onFitToViewer = vi.fn()
const { user } = renderMenuBar({ onFitToViewer })
await user.click(screen.getByRole('button', { name: 'Fit to Viewer' }))
expect(onFitToViewer).toHaveBeenCalledOnce()
})
it('emits centerCamera when the center button is clicked', async () => {
const onCenterCamera = vi.fn()
const { user } = renderMenuBar({ onCenterCamera })
await user.click(
screen.getByRole('button', { name: 'Center Camera on Model' })
)
expect(onCenterCamera).toHaveBeenCalledOnce()
})
it('hides the center button when canCenterCameraOnModel is false', () => {
renderMenuBar({ canCenterCameraOnModel: false })
expect(
screen.queryByRole('button', { name: 'Center Camera on Model' })
).not.toBeInTheDocument()
})
it('toggles the gizmo and reveals the mode controls inline', async () => {
const onToggleGizmo = vi.fn()
const onSetGizmoMode = vi.fn()
const { user } = renderMenuBar({ onToggleGizmo, onSetGizmoMode })
await selectCategory(user, 'Gizmo')
// The chip and the enable toggle share the 'Gizmo' name; click the toggle.
const gizmoButtons = screen.getAllByRole('button', { name: 'Gizmo' })
await user.click(gizmoButtons[gizmoButtons.length - 1])
expect(onToggleGizmo).toHaveBeenCalledWith(true)
await user.click(screen.getByRole('button', { name: 'Rotate' }))
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
})
it('shows the hdri upload inline without an extra popover', async () => {
const { user } = renderMenuBar()
await selectCategory(user, 'HDRI')
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
})
it('forwards removeHdri as updateHdriFile(null) when a file is loaded', async () => {
const onUpdateHdriFile = vi.fn()
const lightConfig = makeLightConfig()
lightConfig.hdri = {
enabled: true,
hdriPath: 'env.hdr',
showAsBackground: false,
intensity: 1
}
const { user } = renderMenuBar({ lightConfig, onUpdateHdriFile })
await selectCategory(user, 'HDRI')
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
})
it('emits startRecording when the record button is clicked', async () => {
const onStartRecording = vi.fn()
const { user } = renderMenuBar({ onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('forwards exportRecording from the recording menu once a recording exists', async () => {
const onExportRecording = vi.fn()
const { user } = renderMenuBar({
hasRecording: true,
isRecording: false,
onExportRecording
})
await user.click(
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
)
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
})
it('omits the gizmo category when canUseGizmo is false', async () => {
const { user } = renderMenuBar({ canUseGizmo: false })
await openCategoryMenu(user)
expect(
screen.queryByRole('button', { name: 'Gizmo' })
).not.toBeInTheDocument()
})
it('switches to the camera category and shows its controls', async () => {
const { user } = renderMenuBar()
await openCategoryMenu(user)
await user.click(screen.getByRole('button', { name: 'Camera' }))
expect(
screen.getByRole('button', { name: 'Perspective' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show grid' })
).not.toBeInTheDocument()
})
it('omits the light category when canUseLighting is false', async () => {
const { user } = renderMenuBar({ canUseLighting: false })
await openCategoryMenu(user)
expect(
screen.queryByRole('button', { name: 'Light' })
).not.toBeInTheDocument()
})
it('hides scene controls when sceneConfig is undefined', () => {
renderMenuBar({ sceneConfig: undefined })
expect(
screen.queryByRole('button', { name: 'Show grid' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,337 @@
<template>
<div class="pointer-events-none absolute inset-0 flex flex-col">
<div
ref="topBarRef"
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<Popover v-model:open="catMenuOpen">
<PopoverTrigger as-child>
<button
:class="chipClass"
type="button"
data-testid="load3d-category-menu"
>
{{ activeLabel }}
<i class="icon-[lucide--chevron-down] size-4 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
v-for="c in categoryDefs"
:key="c.key"
type="button"
:class="
cn(
rowClass,
activeCategory === c.key && 'bg-button-active-surface'
)
"
@click="selectCategory(c.key)"
>
{{ c.label }}
</button>
</PopoverContent>
</Popover>
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
<SceneMenuGroup
v-if="activeCategory === 'scene' && sceneConfig"
v-model:config="sceneConfig"
v-model:fov="cameraFov"
:compact
:can-use-background-image="canUseBackgroundImage"
:hdri-active="hdriActive"
@update-background-image="emit('updateBackgroundImage', $event)"
/>
<ModelMenuGroup
v-else-if="activeCategory === 'model' && modelConfig"
v-model:config="modelConfig"
:compact
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
/>
<CameraMenuGroup
v-else-if="activeCategory === 'camera' && cameraConfig"
v-model:config="cameraConfig"
:compact
/>
<LightMenuGroup
v-else-if="activeCategory === 'light' && lightConfig && modelConfig"
v-model:config="lightConfig"
:compact
:is-original-material="isOriginalMaterial"
/>
<HdriMenuGroup
v-else-if="activeCategory === 'hdri' && lightConfig"
v-model:config="lightConfig"
:compact
:scene-has-image="sceneHasImage"
@update-hdri-file="emit('updateHdriFile', $event)"
/>
<GizmoMenuGroup
v-else-if="activeCategory === 'gizmo' && modelConfig"
v-model:config="modelConfig"
:compact
@toggle-gizmo="emit('toggleGizmo', $event)"
@set-gizmo-mode="emit('setGizmoMode', $event)"
@reset-gizmo-transform="emit('resetGizmoTransform')"
/>
</div>
<div
:class="
cn('flex-1', isRecording && 'border-2 border-node-component-executing')
"
/>
<div
class="pointer-events-auto flex h-10 items-center justify-between gap-1 bg-interface-menu-surface px-2"
@wheel.stop
>
<div class="flex items-center gap-1">
<RecordMenuControl
v-if="canUseRecording"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
:compact
@start-recording="emit('startRecording')"
@stop-recording="emit('stopRecording')"
@export-recording="emit('exportRecording')"
@clear-recording="emit('clearRecording')"
/>
</div>
<div class="flex items-center gap-1">
<ViewerControls
v-if="enableViewer && node"
:node="node as LGraphNode"
/>
<button
v-if="canFitToViewer"
v-tooltip.top="tip(t('load3d.fitToViewer'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.fitToViewer')"
@click="emit('fitToViewer')"
>
<i class="icon-[lucide--scan] size-4" />
</button>
<button
v-if="canCenterCameraOnModel"
v-tooltip.top="tip(t('load3d.centerCameraOnModel'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.centerCameraOnModel')"
@click="emit('centerCamera')"
>
<i class="icon-[lucide--crosshair] size-4" />
</button>
<Popover v-if="canExport" v-model:open="exportOpen">
<PopoverTrigger as-child>
<button
v-tooltip.top="tip(t('load3d.export'))"
:class="iconBtnClass"
type="button"
:aria-label="t('load3d.export')"
>
<i class="icon-[lucide--download] size-4" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
:side-offset="8"
:class="panelClass"
>
<button
v-for="format in exportFormats"
:key="format.value"
type="button"
:class="rowClass"
@click="onExport(format.value)"
>
{{ format.label }}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import { PopoverTrigger } from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
import {
chipClass,
iconBtnClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
CameraConfig,
GizmoMode,
LightConfig,
MaterialMode,
ModelConfig,
SceneConfig
} from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const {
canUseLighting = true,
canUseHdri = true,
canUseGizmo = true,
canExport = true,
canUseBackgroundImage = true,
canFitToViewer = true,
canCenterCameraOnModel = true,
canUseRecording = true,
enableViewer = false,
node = null,
materialModes = ['original', 'clay', 'normal', 'wireframe'],
hasSkeleton = false,
sourceFormat = null
} = defineProps<{
canUseLighting?: boolean
canUseHdri?: boolean
canUseGizmo?: boolean
canExport?: boolean
canUseBackgroundImage?: boolean
canFitToViewer?: boolean
canCenterCameraOnModel?: boolean
canUseRecording?: boolean
enableViewer?: boolean
node?: LGraphNode | null
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
sourceFormat?: string | null
}>()
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
const modelConfig = defineModel<ModelConfig>('modelConfig')
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
const lightConfig = defineModel<LightConfig>('lightConfig')
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'updateBackgroundImage', file: File | null): void
(e: 'updateHdriFile', file: File | null): void
(e: 'exportModel', format: string): void
(e: 'fitToViewer'): void
(e: 'centerCamera'): void
(e: 'toggleGizmo', enabled: boolean): void
(e: 'setGizmoMode', mode: GizmoMode): void
(e: 'resetGizmoTransform'): void
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const categoryDefs = computed(() =>
[
{ key: 'scene', label: t('load3d.scene'), show: !!sceneConfig.value },
{
key: 'model',
label: t('load3d.model3d'),
show: !!modelConfig.value
},
{ key: 'camera', label: t('load3d.camera'), show: !!cameraConfig.value },
{
key: 'light',
label: t('load3d.light'),
show: canUseLighting && !!lightConfig.value && !!modelConfig.value
},
{
key: 'hdri',
label: t('load3d.hdri.label'),
show: canUseHdri && !!lightConfig.value
},
{
key: 'gizmo',
label: t('load3d.gizmo.label'),
show: canUseGizmo && !!modelConfig.value
}
].filter((c) => c.show)
)
const activeCategory = ref('scene')
const activeLabel = computed(
() =>
categoryDefs.value.find((c) => c.key === activeCategory.value)?.label ?? ''
)
watch(categoryDefs, (defs) => {
if (!defs.some((c) => c.key === activeCategory.value)) {
activeCategory.value = defs[0]?.key ?? 'scene'
}
})
const catMenuOpen = ref(false)
const exportOpen = ref(false)
const sceneHasImage = computed(
() =>
!!sceneConfig.value?.backgroundImage &&
sceneConfig.value.backgroundImage !== ''
)
const hdriActive = computed(
() =>
!!lightConfig.value?.hdri?.hdriPath && !!lightConfig.value?.hdri?.enabled
)
const isOriginalMaterial = computed(
() => modelConfig.value?.materialMode === 'original'
)
const cameraFov = computed({
get: () => cameraConfig.value?.fov ?? 0,
set: (value) => {
if (cameraConfig.value) cameraConfig.value.fov = value
}
})
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
const topBarRef = ref<HTMLElement | null>(null)
const { width: topW } = useElementSize(topBarRef)
const compactWidthThreshold = 480
const compact = computed(
() => topW.value > 0 && topW.value < compactWidthThreshold
)
function selectCategory(key: string) {
activeCategory.value = key
catMenuOpen.value = false
}
function onExport(format: string) {
emit('exportModel', format)
exportOpen.value = false
}
</script>

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,138 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
type Props = {
isRecording?: boolean
hasRecording?: boolean
recordingDuration?: number
onStartRecording?: () => void
onStopRecording?: () => void
onExportRecording?: () => void
onClearRecording?: () => void
}
function renderControl(props: Props = {}) {
const result = render(RecordMenuControl, {
props,
global: { plugins: [i18n], directives: { tooltip: () => {} } }
})
return { ...result, user: userEvent.setup() }
}
async function openRecordingMenu(user: ReturnType<typeof userEvent.setup>) {
await user.click(
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
)
}
describe('RecordMenuControl', () => {
it('starts recording when idle', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({ isRecording: false, onStartRecording })
await user.click(screen.getByRole('button', { name: 'Record' }))
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('stops recording when active', async () => {
const onStopRecording = vi.fn()
const { user } = renderControl({ isRecording: true, onStopRecording })
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
expect(onStopRecording).toHaveBeenCalledOnce()
})
it('shows the recording duration once a recording exists', () => {
renderControl({
isRecording: false,
hasRecording: true,
recordingDuration: 65
})
expect(screen.getByText('01:05')).toBeInTheDocument()
})
it('downloads the recording from the menu', async () => {
const onExportRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onExportRecording
})
await openRecordingMenu(user)
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
expect(onExportRecording).toHaveBeenCalledOnce()
})
it('starts a new recording from the menu', async () => {
const onStartRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onStartRecording
})
await openRecordingMenu(user)
await user.click(
screen.getByRole('button', { name: 'Start New Recording' })
)
expect(onStartRecording).toHaveBeenCalledOnce()
})
it('deletes the recording from the menu', async () => {
const onClearRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onClearRecording
})
await openRecordingMenu(user)
await user.click(
within(screen.getByRole('dialog')).getByRole('button', {
name: 'Delete Recording'
})
)
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('deletes the recording via the chip dismiss button', async () => {
const onClearRecording = vi.fn()
const { user } = renderControl({
hasRecording: true,
recordingDuration: 4,
onClearRecording
})
await user.click(screen.getByRole('button', { name: 'Delete Recording' }))
expect(onClearRecording).toHaveBeenCalledOnce()
})
it('shows only the stop control while recording is in progress', () => {
renderControl({ isRecording: true, hasRecording: true })
expect(
screen.queryByRole('button', {
name: 'Video recording of the scene [mp4]'
})
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,141 @@
<template>
<button
v-if="isRecording"
v-tooltip.top="tip(t('load3d.menuBar.stopRecording'))"
:class="chipClass"
type="button"
:aria-label="t('load3d.menuBar.stopRecording')"
@click="emit('stopRecording')"
>
<span class="size-2 animate-pulse rounded-full bg-red-500" />
<span v-if="!compact">{{ t('load3d.menuBar.recording') }}</span>
</button>
<div
v-else-if="hasRecording"
class="flex shrink-0 items-center gap-0.5 rounded-lg bg-button-active-surface py-0.5 pr-0.5 pl-1 text-sm text-base-foreground"
>
<Popover v-model:open="menuOpen">
<PopoverTrigger as-child>
<button
v-tooltip.top="tip(t('load3d.menuBar.videoRecordingTooltip'))"
class="focus-visible:ring-ring flex items-center gap-1.5 rounded-md border-0 bg-transparent px-1 py-0.5 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
type="button"
:aria-label="t('load3d.menuBar.videoRecordingTooltip')"
data-testid="load3d-recording-duration"
>
<i class="icon-[lucide--film] size-4" />
{{ formatDuration(recordingDuration ?? 0) }}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
:side-offset="8"
:class="panelClass"
>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="downloadRecording"
>
<i class="icon-[lucide--download] size-4" />
{{ t('load3d.menuBar.downloadRecording') }}
</button>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="startNewRecording"
>
<i class="icon-[lucide--video] size-4" />
{{ t('load3d.menuBar.startNewRecording') }}
</button>
<button
type="button"
:class="cn(rowClass, 'gap-2')"
@click="deleteRecording"
>
<i class="icon-[lucide--trash-2] size-4" />
{{ t('load3d.menuBar.deleteRecording') }}
</button>
</PopoverContent>
</Popover>
<button
v-tooltip.top="tip(t('load3d.menuBar.deleteRecording'))"
class="focus-visible:ring-ring flex size-6 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
type="button"
:aria-label="t('load3d.menuBar.deleteRecording')"
@click="emit('clearRecording')"
>
<i class="icon-[lucide--x] size-3.5" />
</button>
</div>
<button
v-else
v-tooltip.top="tip(t('load3d.menuBar.record'))"
:class="chipClass"
type="button"
:aria-label="compact ? t('load3d.menuBar.record') : undefined"
@click="emit('startRecording')"
>
<i class="icon-[lucide--video] size-4" />
<span v-if="!compact">{{ t('load3d.menuBar.record') }}</span>
</button>
</template>
<script setup lang="ts">
import { PopoverTrigger } from 'reka-ui'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
chipClass,
panelClass,
rowClass,
tip
} from '@/components/load3d/menubar/menuBarStyles'
import Popover from '@/components/ui/popover/Popover.vue'
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
import { cn } from '@comfyorg/tailwind-utils'
const { compact = false } = defineProps<{
compact?: boolean
}>()
const isRecording = defineModel<boolean>('isRecording')
const hasRecording = defineModel<boolean>('hasRecording')
const recordingDuration = defineModel<number>('recordingDuration')
const emit = defineEmits<{
(e: 'startRecording'): void
(e: 'stopRecording'): void
(e: 'exportRecording'): void
(e: 'clearRecording'): void
}>()
const { t } = useI18n()
const menuOpen = ref(false)
function downloadRecording() {
menuOpen.value = false
emit('exportRecording')
}
function startNewRecording() {
menuOpen.value = false
emit('startRecording')
}
function deleteRecording() {
menuOpen.value = false
emit('clearRecording')
}
function formatDuration(seconds: number) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60)
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
}
</script>

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

@@ -214,9 +214,7 @@ describe('useLoad3d', () => {
getModelInfo: vi.fn().mockReturnValue(null),
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
setAnimationTime: vi.fn(),
renderer: {
domElement: mockCanvas
} as Partial<Load3d['renderer']> as Load3d['renderer']
domElement: mockCanvas
}
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
@@ -303,7 +301,7 @@ describe('useLoad3d', () => {
mockNode.flags.collapsed = true
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
expect(mockLoad3d.domElement!.hidden).toBe(true)
})
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {

View File

@@ -180,6 +180,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const canExport = ref(true)
const materialModes = ref<readonly MaterialMode[]>([
'original',
'clay',
'normal',
'wireframe'
])
@@ -243,7 +244,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
node.onDrawBackground,
function (this: LGraphNode) {
if (load3d) {
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
load3d.domElement.hidden = this.flags.collapsed ?? false
}
}
)

View File

@@ -43,13 +43,8 @@ function makeMockEventManager() {
} satisfies EventManagerInterface
}
function makeRenderer(opts: { withParent?: boolean } = {}) {
const canvas = document.createElement('canvas')
if (opts.withParent) {
const parent = document.createElement('div')
parent.appendChild(canvas)
}
return { domElement: canvas } as unknown as THREE.WebGLRenderer
function makeElement() {
return document.createElement('div')
}
describe('ControlsManager', () => {
@@ -64,33 +59,19 @@ describe('ControlsManager', () => {
})
describe('construction', () => {
it('attaches OrbitControls to the canvas parent when one exists', () => {
const renderer = makeRenderer({ withParent: true })
it('attaches OrbitControls to the interaction element', () => {
const element = makeElement()
manager = new ControlsManager(renderer, camera, events)
manager = new ControlsManager(element, camera, events)
expect(mockOrbitControls).toHaveBeenCalledWith(
camera,
renderer.domElement.parentElement
)
expect(mockOrbitControls).toHaveBeenCalledWith(camera, element)
expect(manager.controls.enableDamping).toBe(true)
})
it('falls back to the canvas itself when there is no parent', () => {
const renderer = makeRenderer({ withParent: false })
manager = new ControlsManager(renderer, camera, events)
expect(mockOrbitControls).toHaveBeenCalledWith(
camera,
renderer.domElement
)
})
})
describe('init', () => {
it('emits cameraChanged with a perspective state when the controls fire end', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
camera.position.set(1, 2, 3)
camera.zoom = 1.25
manager.controls.target.set(4, 5, 6)
@@ -109,7 +90,7 @@ describe('ControlsManager', () => {
it('reports orthographic camera type when initialized with one', () => {
const ortho = new THREE.OrthographicCamera()
ortho.zoom = 0.5
manager = new ControlsManager(makeRenderer(), ortho, events)
manager = new ControlsManager(makeElement(), ortho, events)
manager.init()
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
@@ -123,7 +104,7 @@ describe('ControlsManager', () => {
describe('updateCamera', () => {
it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
camera.position.set(7, 8, 9)
manager.controls.target.set(1, 1, 1)
@@ -139,7 +120,7 @@ describe('ControlsManager', () => {
describe('update / reset', () => {
it('update delegates to controls.update', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
manager.update()
@@ -147,7 +128,7 @@ describe('ControlsManager', () => {
})
it('reset clears the target back to the origin and refreshes', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
manager.controls.target.set(5, 6, 7)
manager.reset()
@@ -159,7 +140,7 @@ describe('ControlsManager', () => {
describe('dispose', () => {
it('disposes the underlying OrbitControls', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
manager.dispose()
@@ -169,7 +150,7 @@ describe('ControlsManager', () => {
describe('detach / attach', () => {
it('detach disables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
expect(manager.controls.enabled).toBe(true)
manager.detach()
@@ -178,7 +159,7 @@ describe('ControlsManager', () => {
})
it('attach re-enables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager = new ControlsManager(makeElement(), camera, events)
manager.detach()
manager.attach()

View File

@@ -12,15 +12,14 @@ export class ControlsManager implements ControlsManagerInterface {
private camera: THREE.Camera
constructor(
renderer: THREE.WebGLRenderer,
interactionElement: HTMLElement,
camera: THREE.Camera,
eventManager: EventManagerInterface
) {
this.eventManager = eventManager
this.camera = camera
const container = renderer.domElement.parentElement || renderer.domElement
this.controls = new OrbitControls(camera, container)
this.controls = new OrbitControls(camera, interactionElement)
this.controls.enableDamping = true
}

View File

@@ -55,7 +55,7 @@ function makeMockOrbitControls() {
describe('GizmoManager', () => {
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let interactionElement: HTMLElement
let camera: THREE.PerspectiveCamera
let orbitControls: ReturnType<typeof makeMockOrbitControls>
let manager: GizmoManager
@@ -66,9 +66,7 @@ describe('GizmoManager', () => {
vi.clearAllMocks()
scene = new THREE.Scene()
renderer = {
domElement: document.createElement('canvas')
} as unknown as THREE.WebGLRenderer
interactionElement = document.createElement('div')
camera = new THREE.PerspectiveCamera()
orbitControls = makeMockOrbitControls()
onTransformChange = vi.fn()
@@ -80,7 +78,7 @@ describe('GizmoManager', () => {
manager = new GizmoManager(
scene,
renderer,
interactionElement,
orbitControls,
() => camera,
onTransformChange

View File

@@ -15,19 +15,19 @@ export class GizmoManager {
private activeCamera: THREE.Camera
private mode: GizmoMode = 'translate'
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private interactionElement: HTMLElement
private orbitControls: OrbitControls
private onTransformChange?: () => void
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
interactionElement: HTMLElement,
orbitControls: OrbitControls,
getActiveCamera: () => THREE.Camera,
onTransformChange?: () => void
) {
this.scene = scene
this.renderer = renderer
this.interactionElement = interactionElement
this.orbitControls = orbitControls
this.activeCamera = getActiveCamera()
this.onTransformChange = onTransformChange
@@ -36,7 +36,7 @@ export class GizmoManager {
init(): void {
this.transformControls = new TransformControls(
this.activeCamera,
this.renderer.domElement
this.interactionElement
)
this.transformControls.addEventListener('dragging-changed', (event) => {

View File

@@ -1,6 +1,8 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import { HDRIManager } from './HDRIManager'
import Load3dUtils from './Load3dUtils'
@@ -76,7 +78,12 @@ describe('HDRIManager', () => {
dispose: vi.fn()
})
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
manager = new HDRIManager(
scene,
{} as THREE.WebGLRenderer,
createRendererViewState(),
eventManager
)
})
afterEach(() => {

View File

@@ -2,12 +2,14 @@ import * as THREE from 'three'
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import Load3dUtils from './Load3dUtils'
import type { EventManagerInterface } from './interfaces'
export class HDRIManager {
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private viewState: RendererViewState
private pmremGenerator: THREE.PMREMGenerator
private eventManager: EventManagerInterface
@@ -33,10 +35,11 @@ export class HDRIManager {
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
viewState: RendererViewState,
eventManager: EventManagerInterface
) {
this.scene = scene
this.renderer = renderer
this.viewState = viewState
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
this.pmremGenerator.compileEquirectangularShader()
this.eventManager = eventManager
@@ -101,8 +104,8 @@ export class HDRIManager {
this.scene.environment = envMap
this.scene.environmentIntensity = this._intensity
this.scene.background = this._showAsBackground ? this.hdriTexture : null
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1.0
this.viewState.toneMapping = THREE.ACESFilmicToneMapping
this.viewState.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: this._isEnabled,
showAsBackground: this._showAsBackground
@@ -114,8 +117,8 @@ export class HDRIManager {
if (this.scene.background === this.hdriTexture) {
this.scene.background = null
}
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 1.0
this.viewState.toneMapping = THREE.NoToneMapping
this.viewState.toneMappingExposure = 1.0
this.eventManager.emitEvent('hdriChange', {
enabled: false,
showAsBackground: this._showAsBackground

View File

@@ -334,7 +334,7 @@ describe('Load3d', () => {
const sceneResize = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
view: { canvas, setSize },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
@@ -361,26 +361,21 @@ describe('Load3d', () => {
const updateAspectRatio = vi.fn()
const renderBackground = vi.fn()
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
value: 800,
configurable: true
})
Object.defineProperty(canvas, 'clientHeight', {
value: 600,
configurable: true
})
const scene = {} as THREE.Scene
Object.assign(ctx.load3d, {
renderer: {
domElement: canvas,
setViewport,
setScissor,
setScissorTest,
setClearColor,
clear,
render
view: {
width: 800,
height: 600,
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
renderer: {
setViewport,
setScissor,
setScissorTest,
setClearColor,
clear,
render
}
},
targetWidth: 400,
targetHeight: 200,
@@ -415,7 +410,7 @@ describe('Load3d', () => {
})
Object.assign(ctx.load3d, {
renderer: { domElement: canvas },
view: { canvas },
targetWidth: 400,
targetHeight: 200,
targetAspectRatio: 2,
@@ -438,7 +433,7 @@ describe('Load3d', () => {
expect(args[3]).toBe(400)
})
it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
it('handleResize scales the view size by getZoomScaleCallback', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
@@ -453,10 +448,10 @@ describe('Load3d', () => {
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setPixelRatio = vi.fn()
const setSize = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
view: { canvas, setSize },
getZoomScaleCallback: () => 2.5,
targetWidth: 0,
targetHeight: 0,
@@ -467,10 +462,10 @@ describe('Load3d', () => {
ctx.load3d.handleResize()
expect(setPixelRatio).toHaveBeenCalledWith(2.5)
expect(setSize).toHaveBeenCalledWith(1000, 1000)
})
it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
it('handleResize caps the zoom scale at 3', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
@@ -485,10 +480,42 @@ describe('Load3d', () => {
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setPixelRatio = vi.fn()
const setSize = vi.fn()
Object.assign(ctx.load3d, {
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
view: { canvas, setSize },
getZoomScaleCallback: () => 10,
targetWidth: 0,
targetHeight: 0,
isViewerMode: false,
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
})
ctx.load3d.handleResize()
expect(setSize).toHaveBeenCalledWith(1200, 1200)
})
it('handleResize defaults to scale 1 when no getZoomScaleCallback is provided', () => {
delete (ctx.load3d as { handleResize?: unknown }).handleResize
const parent = document.createElement('div')
Object.defineProperty(parent, 'clientWidth', {
value: 400,
configurable: true
})
Object.defineProperty(parent, 'clientHeight', {
value: 400,
configurable: true
})
const canvas = document.createElement('canvas')
parent.appendChild(canvas)
const setSize = vi.fn()
Object.assign(ctx.load3d, {
view: { canvas, setSize },
getZoomScaleCallback: undefined,
targetWidth: 0,
targetHeight: 0,
@@ -499,7 +526,7 @@ describe('Load3d', () => {
ctx.load3d.handleResize()
expect(setPixelRatio).toHaveBeenCalledWith(1)
expect(setSize).toHaveBeenCalledWith(400, 400)
})
})
@@ -510,7 +537,8 @@ describe('Load3d', () => {
const viewHelperRender = vi.fn()
const controlsUpdate = vi.fn()
const renderMainScene = vi.fn()
const resetViewport = vi.fn()
const beginRender = vi.fn()
const blit = vi.fn()
Object.assign(ctx.load3d, {
STATUS_MOUSE_ON_NODE: true,
@@ -525,13 +553,16 @@ describe('Load3d', () => {
},
viewHelperManager: {
update: viewHelperUpdate,
viewHelper: { render: viewHelperRender }
render: viewHelperRender
},
controlsManager: { update: controlsUpdate },
recordingManager: { getIsRecording: vi.fn(() => false) },
renderMainScene,
resetViewport,
renderer: {}
view: {
beginRender,
blit,
renderer: { setScissorTest: vi.fn() }
}
})
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
@@ -546,9 +577,10 @@ describe('Load3d', () => {
expect(animationUpdate).toHaveBeenCalledOnce()
expect(viewHelperUpdate).toHaveBeenCalledOnce()
expect(controlsUpdate).toHaveBeenCalledOnce()
expect(beginRender).toHaveBeenCalledOnce()
expect(renderMainScene).toHaveBeenCalledOnce()
expect(resetViewport).toHaveBeenCalledOnce()
expect(viewHelperRender).toHaveBeenCalledOnce()
expect(blit).toHaveBeenCalledOnce()
// Cancel the queued rAF so the test doesn't leak frames.
loop.stop()
@@ -560,12 +592,10 @@ describe('Load3d', () => {
Object.assign(ctx.load3d, {
renderLoop: { stop },
resizeObserver: null,
contextMenuAbortController: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: canvas
view: {
canvas,
dispose: vi.fn()
},
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },

View File

@@ -67,7 +67,7 @@ class Load3d extends Viewport3d {
private hasLoadedModel: boolean = false
constructor(
container: Element | HTMLElement,
container: HTMLElement,
deps: Load3dDeps,
options: Load3DOptions = {}
) {
@@ -255,8 +255,8 @@ class Load3d extends Viewport3d {
this.sceneManager.backgroundTexture &&
this.sceneManager.backgroundMesh
) {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const containerWidth = this.domElement.clientWidth
const containerHeight = this.domElement.clientHeight
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(

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

@@ -64,16 +64,16 @@ function makeStream(): MediaStream {
} as unknown as MediaStream
}
function makeRenderer(): THREE.WebGLRenderer {
function makeSourceCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 600
return { domElement: canvas } as unknown as THREE.WebGLRenderer
return canvas
}
describe('RecordingManager', () => {
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let sourceCanvas: HTMLCanvasElement
let events: ReturnType<typeof makeMockEventManager>
let manager: RecordingManager
let rafSpy: ReturnType<typeof vi.spyOn>
@@ -104,9 +104,9 @@ describe('RecordingManager', () => {
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
scene = new THREE.Scene()
renderer = makeRenderer()
sourceCanvas = makeSourceCanvas()
events = makeMockEventManager()
manager = new RecordingManager(scene, renderer, events)
manager = new RecordingManager(scene, sourceCanvas, events)
})
afterEach(() => {

View File

@@ -11,7 +11,7 @@ export class RecordingManager {
private recordingStream: MediaStream | null = null
private recordingIndicator: THREE.Sprite | null = null
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private sourceCanvas: HTMLCanvasElement
private eventManager: EventManagerInterface
private recordingStartTime: number = 0
private recordingDuration: number = 0
@@ -21,11 +21,11 @@ export class RecordingManager {
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
sourceCanvas: HTMLCanvasElement,
eventManager: EventManagerInterface
) {
this.scene = scene
this.renderer = renderer
this.sourceCanvas = sourceCanvas
this.eventManager = eventManager
this.setupRecordingIndicator()
}
@@ -61,7 +61,7 @@ export class RecordingManager {
}
try {
const sourceCanvas = this.renderer.domElement
const sourceCanvas = this.sourceCanvas
const sourceWidth = sourceCanvas.width
const sourceHeight = sourceCanvas.height

View File

@@ -3,6 +3,9 @@ import * as THREE from 'three'
import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RendererView } from '@/renderer/three/RendererView'
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import type { EventManagerInterface } from './interfaces'
import Load3dUtils from './Load3dUtils'
import { SceneManager } from './SceneManager'
@@ -65,6 +68,32 @@ function makeMockEventManager() {
} satisfies EventManagerInterface
}
function makeView(
renderer: THREE.WebGLRenderer,
width = 400,
height = 300
): RendererView {
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
configurable: true,
value: width
})
Object.defineProperty(canvas, 'clientHeight', {
configurable: true,
value: height
})
return {
renderer,
canvas,
state: createRendererViewState(),
width,
height,
beginRender: vi.fn(),
blit: vi.fn(),
setSize: vi.fn()
} as unknown as RendererView
}
function makeRenderer() {
const canvas = document.createElement('canvas')
Object.defineProperty(canvas, 'clientWidth', {
@@ -120,7 +149,7 @@ describe('SceneManager', () => {
camera = new THREE.PerspectiveCamera()
events = makeMockEventManager()
manager = new SceneManager(
renderer,
makeView(renderer),
() => camera,
() => ({}) as unknown as OrbitControls,
events
@@ -592,20 +621,22 @@ describe('SceneManager', () => {
function makeSceneManager(
pixelRatio = 1,
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera,
viewSize?: { width: number; height: number }
) {
const renderer = makeMockRenderer(pixelRatio)
const view = makeView(renderer, viewSize?.width, viewSize?.height)
const camera = cameraOverride ?? new THREE.PerspectiveCamera()
const eventManager = makeMockEventManager()
const manager = new SceneManager(
renderer,
view,
() => camera,
vi.fn() as unknown as () => InstanceType<
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
>,
eventManager
)
return { manager, renderer, camera, eventManager }
return { manager, renderer, view, camera, eventManager }
}
describe('SceneManager.captureScene', () => {
@@ -648,6 +679,19 @@ describe('SceneManager.captureScene', () => {
expect(calls.at(-1)).toEqual([400, 300])
})
it('restores the view state first and resizes the background to the view size, not the shared buffer size', async () => {
const { manager, view } = makeSceneManager(1, undefined, {
width: 640,
height: 480
})
const handleResize = vi.spyOn(manager, 'handleResize')
await manager.captureScene(1920, 1080)
expect(view.beginRender).toHaveBeenCalledOnce()
expect(handleResize).toHaveBeenCalledWith(640, 480)
})
it('restores perspective camera aspect after capture', async () => {
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
const { manager } = makeSceneManager(1, camera)

View File

@@ -2,6 +2,8 @@ import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { RendererView } from '@/renderer/three/RendererView'
import Load3dUtils from './Load3dUtils'
import {
type BackgroundRenderModeType,
@@ -38,16 +40,18 @@ export class SceneManager implements SceneManagerInterface {
private eventManager: EventManagerInterface
private renderer: THREE.WebGLRenderer
private view: RendererView
private getActiveCamera: () => THREE.Camera
constructor(
renderer: THREE.WebGLRenderer,
view: RendererView,
getActiveCamera: () => THREE.Camera,
_getControls: () => OrbitControls,
eventManager: EventManagerInterface
) {
this.renderer = renderer
this.view = view
this.renderer = view.renderer
this.eventManager = eventManager
this.scene = new THREE.Scene()
@@ -65,7 +69,7 @@ export class SceneManager implements SceneManagerInterface {
// forceRender directly into onDirty caused a per-frame render-setDirty
// cascade that made splats visibly "balloon" during camera interaction.
this.sparkRenderer = new SparkRenderer({
renderer,
renderer: view.renderer,
onDirty: () => {
const resolve = this.nextSparkDirtyResolve
this.nextSparkDirtyResolve = null
@@ -104,7 +108,8 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundMesh.position.set(0, 0, 0)
this.backgroundScene.add(this.backgroundMesh)
this.renderer.setClearColor(0x000000, 0)
this.view.state.clearColor.set(0x000000)
this.view.state.clearAlpha = 0
}
init(): void {}
@@ -250,8 +255,8 @@ export class SceneManager implements SceneManagerInterface {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
this.renderer.domElement.clientWidth,
this.renderer.domElement.clientHeight
this.view.canvas.clientWidth,
this.view.canvas.clientHeight
)
}
@@ -369,6 +374,8 @@ export class SceneManager implements SceneManagerInterface {
width: number,
height: number
): Promise<{ scene: string; mask: string; normal: string }> {
this.view.beginRender()
const originalSize = new THREE.Vector2()
this.renderer.getSize(originalSize)
const originalPixelRatio = this.renderer.getPixelRatio()
@@ -495,7 +502,10 @@ export class SceneManager implements SceneManagerInterface {
this.renderer.setPixelRatio(originalPixelRatio)
this.renderer.setSize(originalSize.x, originalSize.y)
this.renderer.outputColorSpace = originalOutputColorSpace
this.handleResize(originalSize.x, originalSize.y)
this.handleResize(
this.view.canvas.clientWidth,
this.view.canvas.clientHeight
)
}
}

View File

@@ -2,18 +2,13 @@ import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { SceneModelManager } from './SceneModelManager'
import type { EventManagerInterface } from './interfaces'
function createMockRenderer(): THREE.WebGLRenderer {
return {
outputColorSpace: THREE.SRGBColorSpace,
dispose: vi.fn()
} as unknown as THREE.WebGLRenderer
}
function createMockEventManager(): EventManagerInterface {
return {
addEventListener: vi.fn(),
@@ -30,7 +25,7 @@ function createManager(
} = {}
) {
const scene = overrides.scene ?? new THREE.Scene()
const renderer = createMockRenderer()
const viewState = createRendererViewState()
const eventManager = overrides.eventManager ?? createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const getActiveCamera = () => camera
@@ -43,7 +38,7 @@ function createManager(
const manager = new SceneModelManager(
scene,
renderer,
viewState,
eventManager,
getActiveCamera,
setupCamera,
@@ -54,7 +49,7 @@ function createManager(
return {
manager,
scene,
renderer,
viewState,
eventManager,
camera,
setupCamera,
@@ -67,7 +62,6 @@ function createManagerWithPose(opts: {
pose: { size: THREE.Vector3; center: THREE.Vector3 } | null
}) {
const scene = new THREE.Scene()
const renderer = createMockRenderer()
const eventManager = createMockEventManager()
const camera = new THREE.PerspectiveCamera()
const setupCamera = vi.fn()
@@ -79,7 +73,7 @@ function createManagerWithPose(opts: {
const manager = new SceneModelManager(
scene,
renderer,
createRendererViewState(),
eventManager,
() => camera,
setupCamera,
@@ -474,7 +468,7 @@ describe('SceneModelManager', () => {
})
it('switches to depth material', async () => {
const { manager, renderer } = createManager()
const { manager, viewState } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
@@ -482,7 +476,7 @@ describe('SceneModelManager', () => {
const mesh = model.children[0] as THREE.Mesh
expect(mesh.material).toBeInstanceOf(THREE.MeshDepthMaterial)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
})
it('restores original material when switching back', async () => {
@@ -513,16 +507,16 @@ describe('SceneModelManager', () => {
expect((mesh.material as THREE.MeshStandardMaterial).map).toBe(texture)
})
it('sets renderer color space to SRGB for non-depth modes', async () => {
const { manager, renderer } = createManager()
it('sets view color space to SRGB for non-depth modes', async () => {
const { manager, viewState } = createManager()
const model = createMeshModel()
await manager.setupModel(model)
manager.setMaterialMode('depth')
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(viewState.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
manager.setMaterialMode('normal')
expect(renderer.outputColorSpace).toBe(THREE.SRGBColorSpace)
expect(viewState.outputColorSpace).toBe(THREE.SRGBColorSpace)
})
it('delegates to handlePLYModeSwitch for BufferGeometry original model', async () => {

View File

@@ -2,6 +2,8 @@ import { SparkRenderer } from '@sparkjsdev/spark'
import * as THREE from 'three'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapterCapabilities } from './ModelAdapter'
import { buildPointCloudForMaterialMode } from './PointCloudModelAdapter'
@@ -29,6 +31,7 @@ export class SceneModelManager implements ModelManagerInterface {
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
clayMaterial: THREE.MeshStandardMaterial
originalFileName: string | null = null
originalURL: string | null = null
appliedTexture: THREE.Texture | null = null
@@ -37,7 +40,7 @@ export class SceneModelManager implements ModelManagerInterface {
showSkeleton: boolean = false
private scene: THREE.Scene
private renderer: THREE.WebGLRenderer
private viewState: RendererViewState
private eventManager: EventManagerInterface
private activeCamera: THREE.Camera
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
@@ -52,7 +55,7 @@ export class SceneModelManager implements ModelManagerInterface {
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
viewState: RendererViewState,
eventManager: EventManagerInterface,
getActiveCamera: () => THREE.Camera,
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
@@ -68,7 +71,7 @@ export class SceneModelManager implements ModelManagerInterface {
} | null = () => null
) {
this.scene = scene
this.renderer = renderer
this.viewState = viewState
this.eventManager = eventManager
this.activeCamera = getActiveCamera()
this.setupCamera = setupCamera
@@ -98,8 +101,44 @@ export class SceneModelManager implements ModelManagerInterface {
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
this.depthMaterial.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value: this.activeCamera instanceof THREE.OrthographicCamera ? 1.0 : 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
this.depthMaterial.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
this.standardMaterial = this.createSTLMaterial()
this.clayMaterial = new THREE.MeshStandardMaterial({
color: 0x888888,
metalness: 0.0,
roughness: 0.9,
flatShading: false,
side: THREE.DoubleSide
})
}
init(): void {}
@@ -110,6 +149,7 @@ export class SceneModelManager implements ModelManagerInterface {
this.standardMaterial.dispose()
this.wireframeMaterial.dispose()
this.depthMaterial.dispose()
this.clayMaterial.dispose()
if (this.appliedTexture) {
this.appliedTexture.dispose()
@@ -196,9 +236,9 @@ export class SceneModelManager implements ModelManagerInterface {
}
if (mode === 'depth') {
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
this.viewState.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.viewState.outputColorSpace = THREE.SRGBColorSpace
}
if (this.currentModel) {
@@ -212,68 +252,25 @@ export class SceneModelManager implements ModelManagerInterface {
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
const depthMat = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
depthMat.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value:
this.activeCamera instanceof THREE.OrthographicCamera
? 1.0
: 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
depthMat.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
child.material = depthMat
child.material = this.depthMaterial
break
case 'normal':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
child.material = this.normalMaterial
break
case 'wireframe':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
child.material = this.wireframeMaterial
break
case 'clay':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = this.clayMaterial
break
case 'original':
case 'pointCloud':

View File

@@ -15,6 +15,16 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
private readonly helperCamera = new THREE.OrthographicCamera(
-2,
2,
2,
-2,
0,
4
)
private readonly savedViewport = new THREE.Vector4()
constructor(
_renderer: THREE.WebGLRenderer,
getActiveCamera: () => THREE.Camera,
@@ -24,10 +34,25 @@ export class ViewHelperManager implements ViewHelperManagerInterface {
this.getActiveCamera = getActiveCamera
this.getControls = getControls
this.eventManager = eventManager
this.helperCamera.position.set(0, 0, 2)
}
init(): void {}
render(renderer: THREE.WebGLRenderer, size: number): void {
const helper = this.viewHelper
if (!helper.isViewHelper) return
helper.quaternion.copy(this.getActiveCamera().quaternion).invert()
helper.updateMatrixWorld()
renderer.clearDepth()
renderer.getViewport(this.savedViewport)
renderer.setViewport(0, 0, size, size)
renderer.render(helper, this.helperCamera)
renderer.setViewport(this.savedViewport)
}
dispose(): void {
if (this.viewHelper) {
this.viewHelper.dispose()

View File

@@ -394,14 +394,10 @@ describe('Viewport3d', () => {
initialRenderTimer: null,
startAnimation: vi.fn(),
renderLoop: { stop: vi.fn() },
resizeObserver: null,
disposeContextMenuGuard: null,
renderer: {
forceContextLoss: vi.fn(),
dispose: vi.fn(),
domElement: Object.assign(document.createElement('canvas'), {
remove: vi.fn()
})
view: {
canvas: document.createElement('canvas'),
dispose: vi.fn()
},
disposeManagers: vi.fn()
})

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import { WebGLViewport } from '@/renderer/three/WebGLViewport'
import type { RendererView } from '@/renderer/three/RendererView'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
@@ -19,8 +19,10 @@ import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
const VIEW_HELPER_SIZE = 128
export type Viewport3dDeps = {
renderer: THREE.WebGLRenderer
view: RendererView
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
@@ -29,7 +31,8 @@ export type Viewport3dDeps = {
viewHelperManager: ViewHelperManager
}
export class Viewport3d extends WebGLViewport {
export class Viewport3d {
protected readonly view: RendererView
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
@@ -57,13 +60,14 @@ export class Viewport3d extends WebGLViewport {
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
private viewPixelScale = 1
constructor(
container: Element | HTMLElement,
container: HTMLElement,
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
super(deps.renderer)
this.view = deps.view
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
@@ -94,7 +98,15 @@ export class Viewport3d extends WebGLViewport {
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.observeResize(container, () => this.handleResize())
this.view.observeResize(container, () => this.handleResize())
}
get renderer(): THREE.WebGLRenderer {
return this.view.renderer
}
get domElement(): HTMLCanvasElement {
return this.view.canvas
}
start(): void {
@@ -120,7 +132,7 @@ export class Viewport3d extends WebGLViewport {
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
this.view.canvas,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
@@ -159,17 +171,23 @@ export class Viewport3d extends WebGLViewport {
forceRender(): void {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.renderView()
this.INITIAL_RENDER_DONE = true
}
private renderView(): void {
this.view.beginRender()
this.renderMainScene()
this.renderer.setScissorTest(false)
this.viewHelperManager.render(
this.renderer,
VIEW_HELPER_SIZE * this.viewPixelScale
)
this.view.blit()
}
protected tickPerFrame(delta: number): void {
this.overlay?.update?.(delta)
this.viewHelperManager.update(delta)
@@ -219,8 +237,8 @@ export class Viewport3d extends WebGLViewport {
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
const viewWidth = this.view.width
const viewHeight = this.view.height
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
@@ -229,15 +247,16 @@ export class Viewport3d extends WebGLViewport {
}
}
this.renderer.setViewport(0, 0, viewWidth, viewHeight)
this.renderer.setScissor(0, 0, viewWidth, viewHeight)
this.renderer.setScissorTest(true)
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
{ width: viewWidth, height: viewHeight },
this.targetAspectRatio
)
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(0x0a0a0a)
this.renderer.clear()
@@ -246,34 +265,23 @@ export class Viewport3d extends WebGLViewport {
this.cameraManager.updateAspectRatio(width / height)
} else {
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
this.renderer.setClearColor(
this.view.state.clearColor,
this.view.state.clearAlpha
)
this.renderer.clear()
}
this.sceneManager.renderBackground()
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.tickPerFrame(delta)
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.renderView()
},
isActive: () => this.isActive()
})
@@ -346,7 +354,7 @@ export class Viewport3d extends WebGLViewport {
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
const parentElement = this.view.canvas.parentElement
if (!parentElement) {
console.warn('Parent element not found')
@@ -357,7 +365,7 @@ export class Viewport3d extends WebGLViewport {
const containerHeight = parentElement.clientHeight
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
this.viewPixelScale = Math.min(zoomScale, 3)
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
@@ -366,17 +374,20 @@ export class Viewport3d extends WebGLViewport {
}
}
this.view.setSize(
containerWidth * this.viewPixelScale,
containerHeight * this.viewPixelScale
)
if (this.shouldMaintainAspectRatio()) {
const { width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
this.targetAspectRatio
)
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(width, height)
this.sceneManager.handleResize(width, height)
} else {
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
@@ -398,7 +409,7 @@ export class Viewport3d extends WebGLViewport {
this.disposeManagers()
this.disposeRenderer()
this.view.dispose()
}
protected disposeManagers(): void {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { ModelAdapter, ModelAdapterCapabilities } from './ModelAdapter'
@@ -20,7 +20,14 @@ vi.mock('three', async () => {
rendererCtor(opts)
}
setSize() {}
setPixelRatio() {}
setClearColor() {}
getSize(target: { set(x: number, y: number): unknown }) {
target.set(300, 300)
return target
}
forceContextLoss() {}
dispose() {}
}
}
})
@@ -163,6 +170,12 @@ function makeAdapter(overrides: Partial<ModelAdapter> = {}): ModelAdapter {
}
describe('createLoad3d', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage: vi.fn()
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
})
it('constructs the renderer with alpha + antialias and appends it to the container', () => {
rendererCtor.mockClear()
const container = createContainer()

View File

@@ -1,4 +1,6 @@
import * as THREE from 'three'
import type * as THREE from 'three'
import { RendererView } from '@/renderer/three/RendererView'
import { AnimationManager } from './AnimationManager'
import { CameraManager } from './CameraManager'
@@ -17,25 +19,9 @@ import { SceneModelManager } from './SceneModelManager'
import { ViewHelperManager } from './ViewHelperManager'
import type { Load3DOptions } from './interfaces'
function createRenderer(container: Element | HTMLElement): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
renderer.setSize(300, 300)
renderer.setClearColor(0x282828)
renderer.autoClear = false
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
container.appendChild(renderer.domElement)
return renderer
}
function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
const renderer = createRenderer(container)
function buildLoad3dDeps(container: HTMLElement): Load3dDeps {
const view = new RendererView(container)
const renderer = view.renderer
const eventManager = new EventManager()
// Shared mutable handle: LoaderManager writes the active adapter on each
// load; SceneModelManager reads it for capability/bounds/dispose lookups
@@ -50,7 +36,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
const getControls = () => controlsManager.controls
const sceneManager = new SceneManager(
renderer,
view,
getActiveCamera,
getControls,
eventManager
@@ -58,7 +44,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
cameraManager = new CameraManager(renderer, eventManager)
controlsManager = new ControlsManager(
renderer,
container,
cameraManager.activeCamera,
eventManager
)
@@ -68,6 +54,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
const hdriManager = new HDRIManager(
sceneManager.scene,
renderer,
view.state,
eventManager
)
const viewHelperManager = new ViewHelperManager(
@@ -79,7 +66,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
const modelManager = new SceneModelManager(
sceneManager.scene,
renderer,
view.state,
eventManager,
getActiveCamera,
(size, center) => cameraManager.setupForModel(size, center),
@@ -98,14 +85,14 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
)
const recordingManager = new RecordingManager(
sceneManager.scene,
renderer,
view.canvas,
eventManager
)
const animationManager = new AnimationManager(eventManager)
gizmoManager = new GizmoManager(
sceneManager.scene,
renderer,
container,
controlsManager.controls,
getActiveCamera,
() => {
@@ -119,7 +106,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
)
return {
renderer,
view,
eventManager,
sceneManager,
cameraManager,
@@ -137,7 +124,7 @@ function buildLoad3dDeps(container: Element | HTMLElement): Load3dDeps {
}
export function createLoad3d(
container: Element | HTMLElement,
container: HTMLElement,
options?: Load3DOptions
): Load3d {
return new Load3d(container, buildLoad3dDeps(container), options)

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

@@ -2068,6 +2068,7 @@
"centerCameraOnModel": "Center Camera on Model",
"scene": "Scene",
"model": "Model",
"model3d": "3D Model",
"camera": "Camera",
"light": "Light",
"switchingMaterialMode": "Switching Material Mode...",
@@ -2078,13 +2079,35 @@
"reloadingModel": "Reloading model...",
"uploadTexture": "Upload Texture",
"applyingTexture": "Applying Texture...",
"menuBar": {
"showGrid": "Show grid",
"bgColor": "BG Color",
"bgImage": "BG Image",
"panorama": "Panorama",
"removeBackground": "Remove BG",
"upDirection": "Up Direction",
"material": "Material",
"skeleton": "Skeleton",
"fov": "FOV",
"intensity": "Intensity",
"record": "Record",
"recording": "Recording",
"stopRecording": "Stop recording",
"downloadRecording": "Download Recording",
"startNewRecording": "Start New Recording",
"deleteRecording": "Delete Recording",
"videoRecordingTooltip": "Video recording of the scene [mp4]",
"switchProjection": "Switch projection",
"originalMaterialOnly": "Original material only"
},
"materialModes": {
"normal": "Normal",
"wireframe": "Wireframe",
"original": "Original",
"pointCloud": "Point Cloud",
"depth": "Depth",
"lineart": "Lineart"
"lineart": "Lineart",
"clay": "Clay"
},
"upDirections": {
"original": "Original"
@@ -2116,10 +2139,10 @@
"uploadingModel": "Uploading 3D model...",
"loadingHDRI": "Loading HDRI...",
"hdri": {
"label": "HDRI Environment",
"uploadFile": "Upload HDRI (.hdr, .exr)",
"changeFile": "Change HDRI",
"removeFile": "Remove HDRI",
"label": "HDRI",
"uploadFile": "Upload",
"changeFile": "Change",
"removeFile": "Remove",
"showAsBackground": "Show as Background",
"intensity": "Intensity"
},
@@ -2129,7 +2152,7 @@
"translate": "Translate",
"rotate": "Rotate",
"scale": "Scale",
"reset": "Reset Transform"
"reset": "Reset"
}
},
"imageCrop": {

View File

@@ -135,25 +135,22 @@ describe('FormDropdownMenu', () => {
})
/** Regression: PrimeVue Popover teleports the menu to document.body, so
* trackpad pinch-zoom and horizontal swipes must be guarded on the menu
* itself rather than relying on the LGraphNode wheel handler. */
* trackpad pinch-zoom must be guarded on the menu itself — `LGraphNode`
* never sees these events. macOS pinch synthesizes `ctrlKey`; explicit
* `⌘ + wheel` (and Windows/Linux equivalents) come through as `metaKey`. */
it.for([
{ name: 'pinch-zoom', overrides: { ctrlKey: true, deltaY: -10 } },
{ name: 'horizontal swipe', overrides: { deltaX: 30, deltaY: 5 } }
])('suppresses browser default for $name', ({ overrides }) => {
{ name: 'ctrlKey', modifier: 'ctrlKey' as const },
{ name: 'metaKey', modifier: 'metaKey' as const }
])('suppresses browser default for pinch-zoom ($name)', ({ modifier }) => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
bubbles: true,
cancelable: true
})
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
})
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true })
Object.defineProperty(event, modifier, { value: true })
Object.defineProperty(event, 'deltaY', { value: -10 })
root.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
@@ -211,19 +208,33 @@ describe('FormDropdownMenu', () => {
expect(emitted('show-picker')).toHaveLength(1)
})
/** Vertical scrolling must remain native so the dropdown's own scroll
* container can scroll its content. */
it('does not suppress vertical scroll', () => {
/** Regression: slow trackpad vertical scrolls emit small-delta frames with
* stray horizontal jitter (`|deltaX| > |deltaY|`). preventDefault-ing those
* cancelled the native scroll and starved the VirtualGrid's `useScroll`,
* leaving rows blank. Horizontal-swipe-to-navigate is blocked at the page
* boundary by `overscroll-behavior: none` on `html, body`, so the local
* handler must NOT preventDefault these events. The `pure vertical` case
* also covers plain native scrolling of the dropdown's own content. */
it.for([
{ name: 'pure vertical', overrides: { deltaY: 30 } },
{
name: 'slow scroll with horizontal jitter',
overrides: { deltaX: 1.5, deltaY: 1.2 }
},
{
name: 'pronounced horizontal swipe',
overrides: { deltaX: 30, deltaY: 5 }
}
])('does not suppress $name wheel', ({ overrides }) => {
render(FormDropdownMenu, {
props: defaultProps,
global: globalConfig
})
const root = screen.getByTestId('form-dropdown-menu')
const event = new WheelEvent('wheel', {
deltaY: 30,
bubbles: true,
cancelable: true
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true })
Object.entries(overrides).forEach(([key, value]) => {
Object.defineProperty(event, key, { value })
})
root.dispatchEvent(event)

View File

@@ -3,7 +3,6 @@ import type { CSSProperties } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import { isCanvasGestureWheel } from '@/base/wheelGestures'
import type {
FilterOption,
@@ -109,12 +108,17 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
/**
* The dropdown content is teleported to `document.body` by PrimeVue Popover,
* detaching it from the LGraphNode subtree where the canvas wheel guard lives.
* Suppress only the destructive browser defaults (page zoom on pinch and
* back/forward on horizontal swipe); regular vertical scrolling still
* scrolls the dropdown's own content.
* Pinch-zoom (`ctrl/meta + wheel`) would otherwise trigger page-level zoom and
* push fixed UI off-screen, so it must be suppressed locally. Horizontal
* swipe-to-navigate is already blocked at the page boundary by
* `overscroll-behavior: none` on `html, body`, so we deliberately do NOT
* preventDefault on horizontal-dominant wheels here — doing so cancels the
* trackpad's native momentum on slow vertical scrolls (where stray horizontal
* jitter frames satisfy `|deltaX| > |deltaY|`), starving the VirtualGrid's
* `useScroll` and leaving rows blank until a fast scroll re-pumps samples.
*/
const onWheel = (event: WheelEvent) => {
if (isCanvasGestureWheel(event)) event.preventDefault()
if (event.ctrlKey || event.metaKey) event.preventDefault()
}
</script>

View File

@@ -0,0 +1,126 @@
import * as THREE from 'three'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RendererView } from './RendererView'
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class WebGLRenderer {
domElement = document.createElement('canvas')
autoClear = true
outputColorSpace = ''
toneMapping = 0
toneMappingExposure = 1
setSize(width: number, height: number) {
this.domElement.width = width
this.domElement.height = height
}
getSize(target: { set(x: number, y: number): unknown }) {
target.set(this.domElement.width, this.domElement.height)
return target
}
setPixelRatio() {}
setClearColor = vi.fn()
forceContextLoss = vi.fn()
dispose = vi.fn()
}
return { ...actual, WebGLRenderer }
})
const drawImage = vi.fn()
beforeEach(() => {
drawImage.mockClear()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
drawImage,
globalCompositeOperation: 'source-over'
} as unknown as ReturnType<HTMLCanvasElement['getContext']>)
})
describe('RendererView', () => {
it('appends its own canvas to the container', () => {
const container = document.createElement('div')
const view = new RendererView(container)
expect(view.canvas.parentElement).toBe(container)
view.dispose()
expect(view.canvas.parentElement).toBeNull()
})
it('setSize resizes the view canvas and grows the shared buffer to fit', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.setSize(640.4, 480.6)
expect(view.width).toBe(640)
expect(view.height).toBe(481)
expect(view.canvas.width).toBe(640)
expect(view.canvas.height).toBe(481)
expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(640)
expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(481)
view.dispose()
})
it('setSize clamps degenerate sizes to 1 pixel', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.setSize(0, -5)
expect(view.width).toBe(1)
expect(view.height).toBe(1)
view.dispose()
})
it('beginRender applies the view state and regrows a shrunken shared buffer', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.setSize(400, 300)
view.state.toneMapping = THREE.ACESFilmicToneMapping
view.state.toneMappingExposure = 0.5
view.state.outputColorSpace = THREE.LinearSRGBColorSpace
view.state.clearAlpha = 0.25
view.renderer.setSize(50, 40)
view.beginRender()
expect(view.renderer.domElement.width).toBeGreaterThanOrEqual(400)
expect(view.renderer.domElement.height).toBeGreaterThanOrEqual(300)
expect(view.renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping)
expect(view.renderer.toneMappingExposure).toBe(0.5)
expect(view.renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(view.renderer.setClearColor).toHaveBeenCalledWith(
view.state.clearColor,
0.25
)
view.dispose()
})
it('blit copies the bottom-left region of the shared buffer into the view canvas', () => {
const container = document.createElement('div')
const view = new RendererView(container)
view.renderer.setSize(1000, 800)
view.setSize(400, 300)
view.blit()
expect(drawImage).toHaveBeenCalledWith(
view.renderer.domElement,
0,
500,
400,
300,
0,
0,
400,
300
)
view.dispose()
})
})

View File

@@ -0,0 +1,88 @@
import type * as THREE from 'three'
import type {
RendererViewState,
SharedRendererHandle
} from './sharedWebGLRenderer'
import {
acquireSharedRenderer,
applyRendererViewState,
createRendererViewState,
ensureRendererSize
} from './sharedWebGLRenderer'
export class RendererView {
readonly renderer: THREE.WebGLRenderer
readonly canvas: HTMLCanvasElement
readonly state: RendererViewState = createRendererViewState()
width = 1
height = 1
private readonly context: CanvasRenderingContext2D
private readonly handle: SharedRendererHandle
private resizeObserver: ResizeObserver | null = null
constructor(container: HTMLElement) {
this.canvas = document.createElement('canvas')
this.canvas.classList.add(
'absolute',
'inset-0',
'h-full',
'w-full',
'outline-none'
)
const context = this.canvas.getContext('2d')
if (!context) {
throw new Error('Failed to create 2D context for 3D view')
}
this.context = context
this.handle = acquireSharedRenderer()
this.renderer = this.handle.renderer
container.appendChild(this.canvas)
}
setSize(width: number, height: number): void {
this.width = Math.max(1, Math.round(width))
this.height = Math.max(1, Math.round(height))
if (this.canvas.width !== this.width) this.canvas.width = this.width
if (this.canvas.height !== this.height) this.canvas.height = this.height
ensureRendererSize(this.renderer, this.width, this.height)
}
beginRender(): void {
ensureRendererSize(this.renderer, this.width, this.height)
applyRendererViewState(this.renderer, this.state)
}
blit(): void {
const source = this.renderer.domElement
this.context.globalCompositeOperation = 'copy'
this.context.drawImage(
source,
0,
source.height - this.height,
this.width,
this.height,
0,
0,
this.width,
this.height
)
}
observeResize(target: Element, onResize: () => void): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => onResize())
this.resizeObserver.observe(target)
}
dispose(): void {
this.resizeObserver?.disconnect()
this.resizeObserver = null
this.canvas.remove()
this.handle.release()
}
}

View File

@@ -0,0 +1,140 @@
import * as THREE from 'three'
import { describe, expect, it, vi } from 'vitest'
import {
acquireSharedRenderer,
applyRendererViewState,
createRendererViewState,
ensureRendererSize
} from './sharedWebGLRenderer'
const { rendererCtor, forceContextLoss, dispose } = vi.hoisted(() => ({
rendererCtor: vi.fn(),
forceContextLoss: vi.fn(),
dispose: vi.fn()
}))
vi.mock('three', async (importOriginal) => {
const actual = await importOriginal<typeof THREE>()
class WebGLRenderer {
domElement = document.createElement('canvas')
autoClear = true
outputColorSpace = ''
toneMapping = 0
toneMappingExposure = 1
width = 0
height = 0
constructor(opts: unknown) {
rendererCtor(opts)
}
setSize(width: number, height: number) {
this.width = width
this.height = height
}
getSize(target: THREE.Vector2) {
target.set(this.width, this.height)
return target
}
setPixelRatio() {}
setClearColor = vi.fn()
forceContextLoss = forceContextLoss
dispose = dispose
}
return { ...actual, WebGLRenderer }
})
describe('acquireSharedRenderer', () => {
it('returns the same renderer for concurrent views and disposes after the last release', () => {
rendererCtor.mockClear()
forceContextLoss.mockClear()
dispose.mockClear()
const first = acquireSharedRenderer()
const second = acquireSharedRenderer()
expect(second.renderer).toBe(first.renderer)
expect(rendererCtor).toHaveBeenCalledTimes(1)
first.release()
expect(dispose).not.toHaveBeenCalled()
const disposedWhenContextLostFired: boolean[] = []
second.renderer.domElement.addEventListener('webglcontextlost', () => {
disposedWhenContextLostFired.push(dispose.mock.calls.length > 0)
})
second.release()
expect(forceContextLoss).toHaveBeenCalledTimes(1)
expect(dispose).toHaveBeenCalledTimes(1)
expect(disposedWhenContextLostFired).toEqual([false])
})
it('creates a fresh renderer after the previous one was torn down', () => {
rendererCtor.mockClear()
const first = acquireSharedRenderer()
first.release()
const second = acquireSharedRenderer()
expect(rendererCtor).toHaveBeenCalledTimes(2)
expect(second.renderer).not.toBe(first.renderer)
second.release()
})
it('ignores double release from the same handle', () => {
dispose.mockClear()
const first = acquireSharedRenderer()
const second = acquireSharedRenderer()
first.release()
first.release()
expect(dispose).not.toHaveBeenCalled()
second.release()
expect(dispose).toHaveBeenCalledTimes(1)
})
})
describe('ensureRendererSize', () => {
it('grows the drawing buffer per-axis but never shrinks it', () => {
const handle = acquireSharedRenderer()
const renderer = handle.renderer
renderer.setSize(300, 300)
ensureRendererSize(renderer, 500, 200)
expect(renderer.getSize(new THREE.Vector2())).toEqual(
new THREE.Vector2(500, 300)
)
ensureRendererSize(renderer, 400, 250)
expect(renderer.getSize(new THREE.Vector2())).toEqual(
new THREE.Vector2(500, 300)
)
handle.release()
})
})
describe('applyRendererViewState', () => {
it('writes the per-view state onto the renderer', () => {
const handle = acquireSharedRenderer()
const renderer = handle.renderer
const state = createRendererViewState()
state.toneMapping = THREE.ACESFilmicToneMapping
state.toneMappingExposure = 0.5
state.outputColorSpace = THREE.LinearSRGBColorSpace
state.clearColor.set(0x123456)
state.clearAlpha = 0.25
applyRendererViewState(renderer, state)
expect(renderer.toneMapping).toBe(THREE.ACESFilmicToneMapping)
expect(renderer.toneMappingExposure).toBe(0.5)
expect(renderer.outputColorSpace).toBe(THREE.LinearSRGBColorSpace)
expect(renderer.setClearColor).toHaveBeenCalledWith(state.clearColor, 0.25)
handle.release()
})
})

View File

@@ -0,0 +1,78 @@
import * as THREE from 'three'
export type RendererViewState = {
toneMapping: THREE.ToneMapping
toneMappingExposure: number
outputColorSpace: THREE.ColorSpace
clearColor: THREE.Color
clearAlpha: number
}
export function createRendererViewState(): RendererViewState {
return {
toneMapping: THREE.NoToneMapping,
toneMappingExposure: 1.0,
outputColorSpace: THREE.SRGBColorSpace,
clearColor: new THREE.Color(0x000000),
clearAlpha: 0
}
}
export function applyRendererViewState(
renderer: THREE.WebGLRenderer,
state: RendererViewState
): void {
renderer.toneMapping = state.toneMapping
renderer.toneMappingExposure = state.toneMappingExposure
renderer.outputColorSpace = state.outputColorSpace
renderer.setClearColor(state.clearColor, state.clearAlpha)
}
export type SharedRendererHandle = {
renderer: THREE.WebGLRenderer
release: () => void
}
let sharedRenderer: THREE.WebGLRenderer | null = null
let viewCount = 0
function createSharedRenderer(): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
renderer.setPixelRatio(1)
renderer.setSize(300, 300)
renderer.autoClear = false
renderer.outputColorSpace = THREE.SRGBColorSpace
return renderer
}
export function acquireSharedRenderer(): SharedRendererHandle {
sharedRenderer ??= createSharedRenderer()
viewCount++
const renderer = sharedRenderer
let released = false
return {
renderer,
release() {
if (released) return
released = true
viewCount--
if (viewCount > 0 || sharedRenderer !== renderer) return
sharedRenderer = null
renderer.forceContextLoss()
renderer.domElement.dispatchEvent(
new Event('webglcontextlost', { bubbles: true, cancelable: true })
)
renderer.dispose()
}
}
}
export function ensureRendererSize(
renderer: THREE.WebGLRenderer,
width: number,
height: number
): void {
const size = renderer.getSize(new THREE.Vector2())
if (size.width >= width && size.height >= height) return
renderer.setSize(Math.max(size.width, width), Math.max(size.height, height))
}