Compare commits

..

4 Commits

Author SHA1 Message Date
jaeone94
c7799a36d5 Merge branch 'main' into jaeone/fix-nodehelp-locale-e2e 2026-06-19 23:32:41 +09:00
jaeone94
ed028a88be Fix LiteGraph hidden widget metadata handling (FE-1014) (#12916)
## Summary

Fixes FE-1014 by making legacy LiteGraph honor backend-provided hidden
widget metadata.

This PR adds a regression test for the Painter node and updates
LiteGraph widget construction so that a backend input spec with `hidden:
true` is mirrored onto the top-level `widget.hidden` property that the
legacy canvas renderer actually reads.

## Problem

Backend node definitions can mark inputs as hidden, for example with
`extra_dict={"hidden": True}`. That metadata already flows into the
frontend widget options as `widget.options.hidden`, which is why Vue
nodes correctly hide those fields.

Legacy LiteGraph, however, does not use `widget.options.hidden` for
canvas visibility. Its rendering, layout, and hit-testing paths check
top-level `widget.hidden` instead. As a result, a field could be hidden
in Vue nodes while still appearing as an editable control in the legacy
LiteGraph canvas.

For affected nodes, this exposes fields that are intended to be
implementation details, schema/version values, or other non-user-facing
inputs.

## Root Cause

The frontend widget construction path copied backend display metadata
into `widget.options`, including:

- `advanced`
- `hidden`

But it did not mirror backend `hidden` metadata into `widget.hidden`.

That created a renderer split:

- Vue nodes and the right panel use `widget.options.hidden`.
- Legacy LiteGraph uses top-level `widget.hidden`.

So backend-hidden widgets were hidden in Vue mode but still visible and
clickable in legacy LiteGraph mode.

## Implementation

The production change is intentionally small and scoped to
backend-provided hidden metadata:

- Continue assigning `inputSpec.hidden` to `widget.options.hidden` as
before.
- When `inputSpec.hidden` is explicitly defined, also assign it to
top-level `widget.hidden`.

This keeps Vue behavior unchanged while making the legacy LiteGraph
renderer receive the same backend hidden signal through the field it
already uses for visibility.

The fix deliberately does not mirror `advanced` into top-level
`widget.advanced`. While investigating this area, I found that many
backend inputs define `advanced`, and changing legacy advanced-widget
behavior would be a much broader behavioral change than FE-1014
requires. This PR only addresses hidden metadata.

## Test Coverage

This PR adds and tightens Painter regression coverage because Painter
currently provides a concrete backend-hidden widget case:

- In Vue mode, the test verifies hidden Painter widgets are not rendered
to the user.
- In legacy LiteGraph mode, the test disables Vue nodes, loads the
Painter workflow, clicks the rows where backend-hidden number widgets
used to be exposed, and verifies the legacy graph editor dialog does not
open.

The legacy test specifically covers the backend-hidden number widgets
`width` and `height`. It uses user-observable behavior rather than
asserting internal widget flags directly.

A follow-up discussion is ongoing about the broader contract between
`widget.options.hidden` and top-level `widget.hidden`, especially for
frontend-extension-only hiding such as Painter `bg_color`. This PR
intentionally keeps that broader renderer-contract question out of scope
and focuses on backend `hidden` metadata from FE-1014.

## Validation

Validated locally with targeted Playwright coverage:

```bash
PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec playwright test browser_tests/tests/painter.spec.ts --project=chromium -g "Does not render hidden standard widgets|Does not open editors for backend-hidden number widget rows"
```

Result:

```text
2 passed
```

Also validated with linting:

```bash
pnpm eslint src/services/litegraphService.ts browser_tests/tests/painter.spec.ts
pnpm eslint browser_tests/tests/painter.spec.ts
```

The commit hooks also passed:

- `oxfmt`
- `oxlint`
- `eslint`
- `pnpm typecheck`
- `pnpm typecheck:browser`

## Notes

The new legacy test was confirmed red before the production fix and
green after the production fix, so it is not a vacuous assertion. The
final cleanup commit only tightens test naming and coordinate handling
while preserving the same regression intent.
2026-06-19 14:05:49 +00:00
Terry Jia
26cd975c1d refactor(load3d): extract Viewport3d base + SceneOverlay protocol (#12987)
## Summary
Split Load3d into Viewport3d (model-agnostic viewport scaffolding) and
Load3d (extends Viewport3d, adds
loader/model/animation/HDRI/recording/gizmo).
Viewport3d exposes only render-loop plumbing, layout, mouse status,
camera orchestration, and the SceneOverlay protocol so future 3D node
viewports can compose it without inheriting model machinery.

- SceneOverlay protocol
(attach/detach/update/onActiveCameraChange/dispose) gives any 3D node a
managed lifecycle slot for plugging scene content into the viewport.
- Viewport3d.setExternalActiveCamera(cam | null) for POV swap: renders
from an externally-owned camera (e.g. a subject camera authored by an
overlay), with OrbitControls detached and the view helper hidden.
- ControlsManager.detach()/attach() back the POV control gating.
- Two-phase init via Viewport3d.start() so subclass field assignments
finish before any render-path code dispatches through overridden
tickPerFrame / isActive.

Behavior preserved for all 5 Load3d consumers (Load3D, Preview3D,
PreviewGaussianSplat, PreviewPointCloud, SaveGLB).
Sets up the upcoming CreateCameraInfo preview and other future
Three.js-based 3D nodes (Pose Editor, Animation Director) to compose
Viewport3d plus their own SceneOverlay implementation.
2026-06-19 07:00:33 -04:00
jaeone94
83d58602b8 test: stabilize node help locale e2e 2026-06-19 18:22:07 +09:00
13 changed files with 12700 additions and 11190 deletions

View File

@@ -2,7 +2,7 @@ import { readFileSync } from 'fs'
import { test } from '@playwright/test'
import type { AppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/utils/appMode'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON

View File

@@ -4,7 +4,6 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
import type { WorkspaceStore } from '@e2e/types/globals'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
// TODO: there might be a better solution for this
@@ -35,56 +34,6 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId('properties-panel')
}
async function setLocaleAndWaitForWorkflowReload(
comfyPage: ComfyPage,
locale: string
) {
await comfyPage.page.evaluate(async (targetLocale) => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) {
throw new Error('No active workflow while waiting for locale reload')
}
const changeTracker = workflow.changeTracker.constructor as unknown as {
isLoadingGraph: boolean
}
let sawLoading = false
const waitForReload = new Promise<void>((resolve, reject) => {
const timeoutAt = performance.now() + 5000
const tick = () => {
if (changeTracker.isLoadingGraph) {
sawLoading = true
}
if (sawLoading && !changeTracker.isLoadingGraph) {
resolve()
return
}
if (performance.now() > timeoutAt) {
reject(
new Error(
`Timed out waiting for workflow reload after setting locale to ${targetLocale}`
)
)
return
}
requestAnimationFrame(tick)
}
tick()
})
await window.app!.extensionManager.setting.set('Comfy.Locale', targetLocale)
await waitForReload
}, locale)
}
test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -398,34 +347,33 @@ test.describe('Node Help', { tag: ['@slow', '@ui'] }, () => {
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
test.describe('Locale-specific documentation', () => {
test.use({ initialSettings: { 'Comfy.Locale': 'ja' } })
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
})
// Set locale to Japanese
await setLocaleAndWaitForWorkflowReload(comfyPage, 'ja')
try {
await comfyPage.workflow.loadWorkflow('default')
const ksamplerNodes =
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
@@ -434,9 +382,7 @@ This is English documentation.
const helpPage = await openSelectionToolboxHelp(comfyPage)
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
} finally {
await setLocaleAndWaitForWorkflowReload(comfyPage, 'en')
}
})
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {

View File

@@ -10,13 +10,16 @@ import {
} from '@e2e/fixtures/utils/painter'
import type { TestGraphAccess } from '@e2e/types/globals'
const HIDDEN_PAINTER_WIDGET_NAMES = ['width', 'height', 'bg_color'] as const
const HIDDEN_PAINTER_NUMBER_WIDGET_NAMES = ['width', 'height'] as const
test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
})
test.describe('Widget rendering', { tag: ['@widget'] }, () => {
test.describe('Widget rendering', () => {
test('Node enforces minimum size', async ({ comfyPage }) => {
const size = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
@@ -28,17 +31,15 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
expect(size![1]).toBeGreaterThanOrEqual(550)
})
test('Width, height, and bg_color standard widgets are hidden', async ({
test('Does not render hidden standard widgets in Vue mode', async ({
comfyPage
}) => {
const hiddenFlags = await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess | undefined
const node = graph?._nodes_by_id?.['1']
return (node?.widgets ?? [])
.filter((w) => ['width', 'height', 'bg_color'].includes(w.name))
.map((w) => w.options.hidden ?? false)
})
expect(hiddenFlags).toEqual([true, true, true])
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
for (const widgetName of HIDDEN_PAINTER_WIDGET_NAMES) {
await expect(node.getByLabel(widgetName, { exact: true })).toBeHidden()
}
})
})
@@ -788,6 +789,49 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
})
})
test.describe(
'Painter legacy LiteGraph rendering',
{ tag: ['@widget', '@canvas'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
})
test('Does not open editors for backend-hidden number widget rows in legacy LiteGraph', async ({
comfyPage
}) => {
const painterNodes = await comfyPage.nodeOps.getNodeRefsByType('Painter')
expect(painterNodes).toHaveLength(1)
const painterNode = painterNodes[0]!
const maskWidget = await painterNode.getWidgetByName('mask')
const maskWidgetClientPosition = await maskWidget.getPosition()
const widgetRowClientHeight = await comfyPage.page.evaluate(
() =>
(window.LiteGraph!.NODE_WIDGET_HEIGHT + 4) *
window.app!.canvas.ds.scale
)
const legacyPrompt = comfyPage.page.locator('.graphdialog')
await expect(legacyPrompt).toBeHidden()
for (const [
index,
widgetName
] of HIDDEN_PAINTER_NUMBER_WIDGET_NAMES.entries()) {
await test.step(`Click ${widgetName} row`, async () => {
await comfyPage.page.mouse.click(
maskWidgetClientPosition.x,
maskWidgetClientPosition.y + widgetRowClientHeight * (index + 1)
)
await comfyPage.nextFrame()
await expect(legacyPrompt).toBeHidden()
})
}
})
}
)
test.describe(
'Painter — input image connection',
{ tag: ['@widget', '@vue-nodes', '@slow'] },

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ vi.mock('three/examples/jsm/controls/OrbitControls', () => {
object: THREE.Camera
domElement: HTMLElement
enableDamping = false
enabled = true
target = new THREE.Vector3()
update = vi.fn()
dispose = vi.fn()
@@ -165,4 +166,24 @@ describe('ControlsManager', () => {
expect(manager.controls.dispose).toHaveBeenCalled()
})
})
describe('detach / attach', () => {
it('detach disables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
expect(manager.controls.enabled).toBe(true)
manager.detach()
expect(manager.controls.enabled).toBe(false)
})
it('attach re-enables OrbitControls interaction', () => {
manager = new ControlsManager(makeRenderer(), camera, events)
manager.detach()
manager.attach()
expect(manager.controls.enabled).toBe(true)
})
})
})

View File

@@ -63,6 +63,15 @@ export class ControlsManager implements ControlsManagerInterface {
camera.position.copy(position)
this.controls.update()
}
detach(): void {
this.controls.enabled = false
}
attach(): void {
this.controls.enabled = true
}
reset(): void {
this.controls.target.set(0, 0, 0)
this.controls.update()

View File

@@ -2,26 +2,19 @@ import * as THREE from 'three'
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { AnimationManager } from './AnimationManager'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { GizmoManager } from './GizmoManager'
import type { HDRIManager } from './HDRIManager'
import type { LightingManager } from './LightingManager'
import type { LoaderManager } from './LoaderManager'
import { DIRECT_EXPORT_FORMATS } from './constants'
import { ModelExporter } from './ModelExporter'
import { DEFAULT_MODEL_CAPABILITIES } from './ModelAdapter'
import type { AdapterRef, ModelAdapterCapabilities } from './ModelAdapter'
import type { RecordingManager } from './RecordingManager'
import type { SceneManager } from './SceneManager'
import type { SceneModelManager } from './SceneModelManager'
import type { ViewHelperManager } from './ViewHelperManager'
import { Viewport3d, type Viewport3dDeps } from './Viewport3d'
import { computeCameraFromMatrices } from './cameraFromMatrices'
import { DIRECT_EXPORT_FORMATS } from './constants'
import type {
CameraState,
CaptureResult,
EventCallback,
GizmoMode,
Load3DOptions,
LoadModelOptions,
@@ -29,20 +22,10 @@ import type {
Model3DTransform,
UpDirection
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Load3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
export type Load3dDeps = Viewport3dDeps & {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -70,22 +53,8 @@ function positionThumbnailCamera(
camera.updateProjectionMatrix()
}
class Load3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
class Load3d extends Viewport3d {
hdriManager: HDRIManager
viewHelperManager: ViewHelperManager
loaderManager: LoaderManager
modelManager: SceneModelManager
recordingManager: RecordingManager
@@ -93,19 +62,8 @@ class Load3d {
gizmoManager: GizmoManager
adapterRef: AdapterRef
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private loadingPromise: Promise<void> | null = null
private _loadGeneration: number = 0
private hasLoadedModel: boolean = false
constructor(
@@ -113,26 +71,9 @@ class Load3d {
deps: Load3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
super(container, deps, options)
if (options.width && options.height) {
this.targetWidth = options.width
this.targetHeight = options.height
this.targetAspectRatio = options.width / options.height
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.hdriManager = deps.hdriManager
this.viewHelperManager = deps.viewHelperManager
this.loaderManager = deps.loaderManager
this.modelManager = deps.modelManager
this.recordingManager = deps.recordingManager
@@ -140,35 +81,16 @@ class Load3d {
this.gizmoManager = deps.gizmoManager
this.adapterRef = deps.adapterRef
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
this.handleResize()
this.startAnimation()
this.eventManager.addEventListener('modelReady', () => {
if (this.adapterRef.current?.kind !== 'splat') return
void this.repaintWhenSparkPaintable()
})
setTimeout(() => {
this.forceRender()
}, 100)
this.start()
}
private async repaintWhenSparkPaintable(): Promise<void> {
@@ -178,49 +100,14 @@ class Load3d {
this.forceRender()
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getLoaderManager(): LoaderManager {
return this.loaderManager
}
getModelManager(): SceneModelManager {
return this.modelManager
}
getRecordingManager(): RecordingManager {
return this.recordingManager
}
@@ -229,119 +116,12 @@ class Load3d {
return this.gizmoManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
private shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
forceRender(): void {
const delta = this.clock.getDelta()
protected override tickPerFrame(delta: number): void {
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
super.tickPerFrame(delta)
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
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()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
this.cameraManager.updateAspectRatio(width / height)
} else {
// No aspect ratio constraint: fill the entire container
this.renderer.setViewport(0, 0, containerWidth, containerHeight)
this.renderer.setScissor(0, 0, containerWidth, containerHeight)
this.renderer.setScissorTest(true)
}
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
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)
}
private startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {
const delta = this.clock.getDelta()
this.animationManager.update(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
override isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
@@ -444,9 +224,27 @@ class Load3d {
return ModelExporter.detectFormatFromURL(url)
}
protected override onActiveCameraChanged(): void {
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
@@ -473,7 +271,6 @@ class Load3d {
height
)
} else {
// No aspect ratio constraints: fill container
this.sceneManager.updateBackgroundSize(
this.sceneManager.backgroundTexture,
this.sceneManager.backgroundMesh,
@@ -488,12 +285,6 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.forceRender()
}
toggleGrid(showGrid: boolean): void {
this.sceneManager.toggleGrid(showGrid)
this.forceRender()
}
@@ -502,39 +293,6 @@ class Load3d {
this.forceRender()
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
this.viewHelperManager.recreateViewHelper()
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setFOV(fov: number): void {
this.cameraManager.setFOV(fov)
this.forceRender()
}
setCameraFromMatrices(
extrinsics: readonly (readonly number[])[],
intrinsics: readonly (readonly number[])[]
@@ -553,19 +311,15 @@ class Load3d {
this.setFOV(fovYDegrees)
}
getCurrentModel(): THREE.Object3D | null {
return this.modelManager.currentModel
}
setMaterialMode(mode: MaterialMode): void {
this.modelManager.setMaterialMode(mode)
this.forceRender()
}
/**
* Monotonic counter that ticks once per loadModel call, **before** any
* await. Callers can capture this immediately after triggering a load and
* later compare against `currentLoadGeneration` to verify their load is
* still the latest one — useful when chaining post-load work
* (e.g. applying camera matrices) through `whenLoadIdle()`, which would
* otherwise wait for any newer queued load and apply stale state to it.
*/
get currentLoadGeneration(): number {
return this._loadGeneration
}
@@ -606,8 +360,6 @@ class Load3d {
originalFileName?: string,
options?: LoadModelOptions
): Promise<void> {
// First load always uses default framing; subsequent reloads preserve
// the user's framing.
const shouldRetainView = this.hasLoadedModel
const savedCameraState = shouldRetainView
? this.cameraManager.getCameraState()
@@ -623,7 +375,6 @@ class Load3d {
await this.loaderManager.loadModel(url, originalFileName, options)
// Auto-detect and setup animations if present
if (this.modelManager.currentModel) {
this.animationManager.setupModelAnimations(
this.modelManager.currentModel,
@@ -633,7 +384,6 @@ class Load3d {
}
if (savedCameraState) {
// setupForModel runs during loadModel and clobbers the camera; restore on top.
if (
savedCameraState.cameraType !==
this.cameraManager.getCurrentCameraType()
@@ -674,11 +424,6 @@ class Load3d {
this.forceRender()
}
setLightIntensity(intensity: number): void {
this.lightingManager.setLightIntensity(intensity)
this.forceRender()
}
async loadHDRI(url: string): Promise<void> {
await this.hdriManager.loadHDRI(url)
this.forceRender()
@@ -706,73 +451,10 @@ class Load3d {
this.forceRender()
}
setTargetSize(width: number, height: number): void {
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
emitModelReady(): void {
this.eventManager.emitEvent('modelReady', null)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
// Scale pixel density to match the graph zoom level so the 3D scene
// renders at the correct resolution when the canvas is zoomed in or out.
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.targetWidth = dims.width
this.targetHeight = dims.height
this.targetAspectRatio = dims.width / dims.height
}
}
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 {
// No aspect ratio constraint: use container dimensions directly
this.renderer.setSize(containerWidth, containerHeight)
this.cameraManager.handleResize(containerWidth, containerHeight)
this.sceneManager.handleResize(containerWidth, containerHeight)
}
this.forceRender()
}
captureScene(width: number, height: number): Promise<CaptureResult> {
this.gizmoManager.removeFromScene()
@@ -818,7 +500,6 @@ class Load3d {
this.recordingManager.clearRecording()
}
// Animation methods
public setAnimationSpeed(speed: number): void {
this.animationManager.setAnimationSpeed(speed)
}
@@ -977,41 +658,15 @@ class Load3d {
this.forceRender()
}
public remove(): void {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
protected override disposeManagers(): void {
super.disposeManagers()
this.hdriManager.dispose()
this.viewHelperManager.dispose()
this.loaderManager.dispose()
this.modelManager.dispose()
this.adapterRef.current = null
this.recordingManager.dispose()
this.animationManager.dispose()
this.gizmoManager.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
}
}

View File

@@ -0,0 +1,433 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
type CameraStub = {
toggleCamera: ReturnType<typeof vi.fn>
setupForModel: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
setFOV: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
updateAspectRatio: ReturnType<typeof vi.fn>
activeCamera: THREE.Camera
}
type SceneStub = {
captureScene: ReturnType<typeof vi.fn>
setBackgroundColor: ReturnType<typeof vi.fn>
setBackgroundImage: ReturnType<typeof vi.fn>
removeBackgroundImage: ReturnType<typeof vi.fn>
toggleGrid: ReturnType<typeof vi.fn>
setBackgroundRenderMode: ReturnType<typeof vi.fn>
handleResize: ReturnType<typeof vi.fn>
renderBackground: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
updateBackgroundSize: ReturnType<typeof vi.fn>
backgroundTexture: unknown
backgroundMesh: unknown
scene: THREE.Scene
}
function makeViewportInstance() {
const cameraManager: CameraStub = {
toggleCamera: vi.fn(),
setupForModel: vi.fn(),
reset: vi.fn(),
getCameraState: vi.fn(() => ({
position: new THREE.Vector3(),
target: new THREE.Vector3(),
zoom: 1,
cameraType: 'perspective' as const
})),
setCameraState: vi.fn(),
setFOV: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective' as const),
handleResize: vi.fn(),
updateAspectRatio: vi.fn(),
activeCamera: new THREE.PerspectiveCamera()
}
const sceneManager: SceneStub = {
captureScene: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
removeBackgroundImage: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundRenderMode: vi.fn(),
handleResize: vi.fn(),
renderBackground: vi.fn(),
dispose: vi.fn(),
updateBackgroundSize: vi.fn(),
backgroundTexture: null,
backgroundMesh: null,
scene: new THREE.Scene()
}
const controlsManager = {
updateCamera: vi.fn(),
update: vi.fn(),
dispose: vi.fn(),
reset: vi.fn(),
detach: vi.fn(),
attach: vi.fn()
}
const lightingManager = {
setLightIntensity: vi.fn(),
dispose: vi.fn()
}
const viewHelperManager = {
recreateViewHelper: vi.fn(),
update: vi.fn(),
visibleViewHelper: vi.fn(),
viewHelper: { render: vi.fn() },
dispose: vi.fn()
}
const eventManager = {
emitEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
const viewport = Object.create(Viewport3d.prototype) as Viewport3d
Object.assign(viewport, {
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: vi.fn(),
handleResize: vi.fn()
})
return {
viewport,
cameraManager,
sceneManager,
controlsManager,
lightingManager,
viewHelperManager,
eventManager,
forceRender: viewport.forceRender as ReturnType<typeof vi.fn>
}
}
describe('Viewport3d', () => {
let ctx: ReturnType<typeof makeViewportInstance>
beforeEach(() => {
ctx = makeViewportInstance()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('camera delegation (model-independent)', () => {
it('toggleCamera updates controls and recreates view helper without touching model state', () => {
ctx.viewport.toggleCamera('orthographic')
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
'orthographic'
)
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
})
})
describe('isActive (no model concerns)', () => {
it('returns false when no mouse activity is present', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(ctx.viewport.isActive()).toBe(false)
})
it('does not consult recording or animation state — that is a Load3d concern', () => {
Object.assign(ctx.viewport, {
STATUS_MOUSE_ON_NODE: false,
STATUS_MOUSE_ON_SCENE: false,
STATUS_MOUSE_ON_VIEWER: false,
INITIAL_RENDER_DONE: true
})
expect(() => ctx.viewport.isActive()).not.toThrow()
})
})
describe('manager accessors', () => {
it('exposes managers through both fields and getters', () => {
expect(ctx.viewport.cameraManager).toBe(ctx.cameraManager)
expect(ctx.viewport.getCameraManager()).toBe(ctx.cameraManager)
expect(ctx.viewport.sceneManager).toBe(ctx.sceneManager)
expect(ctx.viewport.getSceneManager()).toBe(ctx.sceneManager)
expect(ctx.viewport.controlsManager).toBe(ctx.controlsManager)
expect(ctx.viewport.getControlsManager()).toBe(ctx.controlsManager)
expect(ctx.viewport.lightingManager).toBe(ctx.lightingManager)
expect(ctx.viewport.getLightingManager()).toBe(ctx.lightingManager)
expect(ctx.viewport.viewHelperManager).toBe(ctx.viewHelperManager)
expect(ctx.viewport.getViewHelperManager()).toBe(ctx.viewHelperManager)
expect(ctx.viewport.eventManager).toBe(ctx.eventManager)
expect(ctx.viewport.getEventManager()).toBe(ctx.eventManager)
})
})
describe('POV swap (setExternalActiveCamera)', () => {
it('getRenderCamera returns the orbit camera when no external camera is set', () => {
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('getRenderCamera returns the external camera once installed', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.viewport.getRenderCamera()).toBe(subjectCamera)
})
it('installing an external camera detaches controls and hides the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(
false
)
expect(ctx.forceRender).toHaveBeenCalled()
})
it('clearing the external camera re-attaches controls and shows the view helper', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.controlsManager.attach.mockClear()
ctx.viewHelperManager.visibleViewHelper.mockClear()
ctx.viewport.setExternalActiveCamera(null)
expect(ctx.controlsManager.attach).toHaveBeenCalledOnce()
expect(ctx.viewHelperManager.visibleViewHelper).toHaveBeenCalledWith(true)
expect(ctx.viewport.getRenderCamera()).toBe(
ctx.cameraManager.activeCamera
)
})
it('setting the same external camera twice is a no-op', () => {
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setExternalActiveCamera(subjectCamera)
ctx.controlsManager.detach.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(ctx.controlsManager.detach).not.toHaveBeenCalled()
})
})
describe('overlay', () => {
function makeOverlay() {
return {
attach: vi.fn(),
detach: vi.fn(),
update: vi.fn(),
onActiveCameraChange: vi.fn(),
dispose: vi.fn()
}
}
it('setOverlay attaches to the scene and notifies of the current render camera', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
expect(overlay.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
expect(ctx.viewport.getOverlay()).toBe(overlay)
})
it('replacing an overlay detaches and disposes the prior one', () => {
const first = makeOverlay()
const second = makeOverlay()
ctx.viewport.setOverlay(first)
ctx.viewport.setOverlay(second)
expect(first.detach).toHaveBeenCalledOnce()
expect(first.dispose).toHaveBeenCalledOnce()
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
expect(ctx.viewport.getOverlay()).toBe(second)
})
it('removeOverlay detaches and disposes the installed overlay', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
ctx.viewport.removeOverlay()
expect(overlay.detach).toHaveBeenCalledOnce()
expect(overlay.dispose).toHaveBeenCalledOnce()
expect(ctx.viewport.getOverlay()).toBeNull()
})
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
const tick = (
ctx.viewport as unknown as {
tickPerFrame(delta: number): void
}
).tickPerFrame.bind(ctx.viewport)
tick(0.016)
expect(overlay.update).toHaveBeenCalledWith(0.016)
expect(ctx.viewHelperManager.update).toHaveBeenCalledWith(0.016)
expect(ctx.controlsManager.update).toHaveBeenCalledOnce()
})
it('toggleCamera notifies the overlay with the new orbit camera (when no POV)', () => {
const overlay = makeOverlay()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
ctx.cameraManager.activeCamera
)
})
it('toggleCamera does NOT notify the overlay while a POV camera is active', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
ctx.viewport.setExternalActiveCamera(subjectCamera)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.toggleCamera('orthographic')
expect(overlay.onActiveCameraChange).not.toHaveBeenCalled()
})
it('setExternalActiveCamera notifies the overlay with the new render camera', () => {
const overlay = makeOverlay()
const subjectCamera = new THREE.PerspectiveCamera()
ctx.viewport.setOverlay(overlay)
overlay.onActiveCameraChange.mockClear()
ctx.viewport.setExternalActiveCamera(subjectCamera)
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(subjectCamera)
})
})
describe('applyTargetSize guards', () => {
function applyTargetSize(width: number, height: number): void {
;(
ctx.viewport as unknown as {
applyTargetSize(w: number, h: number): void
}
).applyTargetSize(width, height)
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 0,
targetHeight: 0,
targetAspectRatio: 1
})
})
it('writes width / height / aspect when both inputs are positive finite', () => {
applyTargetSize(800, 400)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it.for([
['zero width', 0, 100],
['zero height', 100, 0],
['negative width', -100, 100],
['negative height', 100, -100],
['NaN width', Number.NaN, 100],
['Infinity height', 100, Number.POSITIVE_INFINITY]
] as const)('rejects %s without touching prior state', ([, w, h]) => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
applyTargetSize(w, h)
expect(ctx.viewport.targetWidth).toBe(800)
expect(ctx.viewport.targetHeight).toBe(400)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
it('setTargetSize routes through the guard', () => {
Object.assign(ctx.viewport, {
targetWidth: 800,
targetHeight: 400,
targetAspectRatio: 2
})
ctx.viewport.setTargetSize(0, 0)
expect(ctx.viewport.targetAspectRatio).toBe(2)
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()
Object.assign(ctx.viewport, {
hasStarted: false,
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()
})
},
disposeManagers: vi.fn()
})
})
afterEach(() => {
vi.useRealTimers()
})
it('start schedules a deferred forceRender and remove clears it before the timer fires', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
ctx.viewport.remove()
vi.advanceTimersByTime(500)
expect(ctx.forceRender).not.toHaveBeenCalled()
})
it('the deferred forceRender does fire when remove is not called', () => {
ctx.viewport.start()
ctx.forceRender.mockClear()
vi.advanceTimersByTime(100)
expect(ctx.forceRender).toHaveBeenCalledOnce()
})
})
})

View File

@@ -0,0 +1,440 @@
import * as THREE from 'three'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
import type { EventManager } from './EventManager'
import type { LightingManager } from './LightingManager'
import type { SceneManager } from './SceneManager'
import type { ViewHelperManager } from './ViewHelperManager'
import type {
CameraState,
EventCallback,
Load3DOptions,
SceneOverlay
} from './interfaces'
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
export type Viewport3dDeps = {
renderer: THREE.WebGLRenderer
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
}
export class Viewport3d {
renderer: THREE.WebGLRenderer
protected clock: THREE.Clock
private renderLoop: RenderLoopHandle | null = null
private onContextMenuCallback?: (event: MouseEvent) => void
private getDimensionsCallback?: () => { width: number; height: number } | null
eventManager: EventManager
sceneManager: SceneManager
cameraManager: CameraManager
controlsManager: ControlsManager
lightingManager: LightingManager
viewHelperManager: ViewHelperManager
STATUS_MOUSE_ON_NODE: boolean
STATUS_MOUSE_ON_SCENE: boolean
STATUS_MOUSE_ON_VIEWER: boolean
INITIAL_RENDER_DONE: boolean = false
targetWidth: number = 0
targetHeight: number = 0
targetAspectRatio: number = 1
isViewerMode: boolean = false
private disposeContextMenuGuard: (() => void) | null = null
private resizeObserver: ResizeObserver | null = null
private getZoomScaleCallback: (() => number) | undefined
private externalActiveCamera: THREE.Camera | null = null
private overlay: SceneOverlay | null = null
private initialRenderTimer: ReturnType<typeof setTimeout> | null = null
constructor(
container: Element | HTMLElement,
deps: Viewport3dDeps,
options: Load3DOptions = {}
) {
this.clock = new THREE.Clock()
this.isViewerMode = options.isViewerMode || false
this.onContextMenuCallback = options.onContextMenu
this.getDimensionsCallback = options.getDimensions
this.getZoomScaleCallback = options.getZoomScale
if (options.width !== undefined && options.height !== undefined) {
this.applyTargetSize(options.width, options.height)
}
this.renderer = deps.renderer
this.eventManager = deps.eventManager
this.sceneManager = deps.sceneManager
this.cameraManager = deps.cameraManager
this.controlsManager = deps.controlsManager
this.lightingManager = deps.lightingManager
this.viewHelperManager = deps.viewHelperManager
this.sceneManager.init()
this.cameraManager.init()
this.controlsManager.init()
this.lightingManager.init()
this.viewHelperManager.createViewHelper(container)
this.viewHelperManager.init()
this.STATUS_MOUSE_ON_NODE = false
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.initResizeObserver(container)
}
start(): void {
if (this.hasStarted) return
this.hasStarted = true
this.handleResize()
this.startAnimation()
this.initialRenderTimer = setTimeout(() => {
this.initialRenderTimer = null
this.forceRender()
}, 100)
}
private hasStarted: boolean = false
private applyTargetSize(width: number, height: number): void {
if (!Number.isFinite(width) || !Number.isFinite(height)) return
if (width <= 0 || height <= 0) return
this.targetWidth = width
this.targetHeight = height
this.targetAspectRatio = width / height
}
private initResizeObserver(container: Element | HTMLElement): void {
if (typeof ResizeObserver === 'undefined') return
this.resizeObserver?.disconnect()
this.resizeObserver = new ResizeObserver(() => {
this.handleResize()
})
this.resizeObserver.observe(container)
}
private initContextMenu(): void {
this.disposeContextMenuGuard = attachContextMenuGuard(
this.renderer.domElement,
(event) => this.onContextMenuCallback?.(event),
{ isDisabled: () => this.isViewerMode }
)
}
getEventManager(): EventManager {
return this.eventManager
}
getSceneManager(): SceneManager {
return this.sceneManager
}
getCameraManager(): CameraManager {
return this.cameraManager
}
getControlsManager(): ControlsManager {
return this.controlsManager
}
getLightingManager(): LightingManager {
return this.lightingManager
}
getViewHelperManager(): ViewHelperManager {
return this.viewHelperManager
}
getTargetSize(): { width: number; height: number } {
return {
width: this.targetWidth,
height: this.targetHeight
}
}
protected shouldMaintainAspectRatio(): boolean {
return this.isViewerMode || (this.targetWidth > 0 && this.targetHeight > 0)
}
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.INITIAL_RENDER_DONE = true
}
protected tickPerFrame(delta: number): void {
this.overlay?.update?.(delta)
this.viewHelperManager.update(delta)
this.controlsManager.update()
}
getRenderCamera(): THREE.Camera {
return this.externalActiveCamera ?? this.cameraManager.activeCamera
}
setExternalActiveCamera(camera: THREE.Camera | null): void {
if (this.externalActiveCamera === camera) return
this.externalActiveCamera = camera
if (camera) {
this.controlsManager.detach()
this.viewHelperManager.visibleViewHelper(false)
} else {
this.controlsManager.attach()
this.viewHelperManager.visibleViewHelper(true)
}
this.overlay?.onActiveCameraChange?.(this.getRenderCamera())
this.forceRender()
}
setOverlay(overlay: SceneOverlay): void {
if (this.overlay === overlay) return
if (this.overlay) {
this.overlay.detach()
this.overlay.dispose()
}
this.overlay = overlay
overlay.attach(this.sceneManager.scene)
overlay.onActiveCameraChange?.(this.getRenderCamera())
this.forceRender()
}
removeOverlay(): void {
if (!this.overlay) return
this.overlay.detach()
this.overlay.dispose()
this.overlay = null
this.forceRender()
}
getOverlay(): SceneOverlay | null {
return this.overlay
}
renderMainScene(): void {
const containerWidth = this.renderer.domElement.clientWidth
const containerHeight = this.renderer.domElement.clientHeight
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.applyTargetSize(dims.width, dims.height)
}
}
if (this.shouldMaintainAspectRatio()) {
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
{ width: containerWidth, height: containerHeight },
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()
this.renderer.setViewport(offsetX, offsetY, width, height)
this.renderer.setScissor(offsetX, offsetY, width, height)
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.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)
}
},
isActive: () => this.isActive()
})
}
updateStatusMouseOnNode(onNode: boolean): void {
this.STATUS_MOUSE_ON_NODE = onNode
}
updateStatusMouseOnScene(onScene: boolean): void {
this.STATUS_MOUSE_ON_SCENE = onScene
}
updateStatusMouseOnViewer(onViewer: boolean): void {
this.STATUS_MOUSE_ON_VIEWER = onViewer
}
isActive(): boolean {
return isLoad3dActive({
mouseOnNode: this.STATUS_MOUSE_ON_NODE,
mouseOnScene: this.STATUS_MOUSE_ON_SCENE,
mouseOnViewer: this.STATUS_MOUSE_ON_VIEWER,
recording: false,
initialRenderDone: this.INITIAL_RENDER_DONE,
animationPlaying: false
})
}
toggleCamera(cameraType?: 'perspective' | 'orthographic'): void {
this.cameraManager.toggleCamera(cameraType)
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
this.onActiveCameraChanged()
this.viewHelperManager.recreateViewHelper()
if (!this.externalActiveCamera) {
this.overlay?.onActiveCameraChange?.(this.cameraManager.activeCamera)
}
this.handleResize()
}
protected onActiveCameraChanged(): void {}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.cameraManager.getCurrentCameraType()
}
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
this.forceRender()
}
getCameraState(): CameraState {
return this.cameraManager.getCameraState()
}
setTargetSize(width: number, height: number): void {
this.applyTargetSize(width, height)
this.handleResize()
}
addEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.addEventListener(event, callback)
}
removeEventListener<T>(event: string, callback: EventCallback<T>): void {
this.eventManager.removeEventListener(event, callback)
}
refreshViewport(): void {
this.handleResize()
}
handleResize(): void {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const containerWidth = parentElement.clientWidth
const containerHeight = parentElement.clientHeight
const zoomScale = this.getZoomScaleCallback?.() ?? 1
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
if (this.getDimensionsCallback) {
const dims = this.getDimensionsCallback()
if (dims) {
this.applyTargetSize(dims.width, dims.height)
}
}
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)
}
this.forceRender()
}
remove(): void {
if (this.initialRenderTimer) {
clearTimeout(this.initialRenderTimer)
this.initialRenderTimer = null
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.disposeContextMenuGuard?.()
this.disposeContextMenuGuard = null
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.renderLoop?.stop()
this.renderLoop = null
this.disposeManagers()
this.renderer.dispose()
this.renderer.domElement.remove()
}
protected disposeManagers(): void {
if (this.overlay) {
this.overlay.detach()
this.overlay.dispose()
this.overlay = null
}
this.sceneManager.dispose()
this.cameraManager.dispose()
this.controlsManager.dispose()
this.lightingManager.dispose()
this.viewHelperManager.dispose()
}
}

View File

@@ -244,6 +244,14 @@ export interface LoadModelOptions {
silentOnNotFound?: boolean
}
export interface SceneOverlay {
attach(scene: THREE.Scene): void
detach(): void
update?(deltaSeconds: number): void
onActiveCameraChange?(camera: THREE.Camera): void
dispose(): void
}
export interface LoaderManagerInterface {
init(): void
dispose(): void

View File

@@ -302,6 +302,7 @@ export const useLitegraphService = () => {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
if (inputSpec.hidden !== undefined) widget.hidden = inputSpec.hidden
if (dynamic) widget.tooltip = inputSpec.tooltip
}