mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-31 10:25:03 +00:00
Compare commits
1 Commits
main
...
feat/previ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dec5aa6eb |
@@ -71,7 +71,6 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:retain-view-on-reload="cameraConfig!.retainViewOnReload"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -18,32 +18,10 @@
|
||||
v-model="fov"
|
||||
:tooltip-text="$t('load3d.fov')"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.retainViewOnReload'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.retainViewOnReload')"
|
||||
:aria-pressed="retainViewOnReload"
|
||||
@click="retainViewOnReload = !retainViewOnReload"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'pi text-lg text-base-foreground',
|
||||
retainViewOnReload ? 'pi-lock' : 'pi-lock-open'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
@@ -52,9 +30,6 @@ import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const retainViewOnReload = defineModel<boolean>('retainViewOnReload', {
|
||||
default: false
|
||||
})
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
|
||||
@@ -144,7 +144,6 @@ describe('useLoad3d', () => {
|
||||
setMaterialMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setRetainViewOnReload: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -570,21 +569,17 @@ describe('useLoad3d', () => {
|
||||
|
||||
vi.mocked(mockLoad3d.toggleCamera!).mockClear()
|
||||
vi.mocked(mockLoad3d.setFOV!).mockClear()
|
||||
vi.mocked(mockLoad3d.setRetainViewOnReload!).mockClear()
|
||||
|
||||
composable.cameraConfig.value.cameraType = 'orthographic'
|
||||
composable.cameraConfig.value.fov = 90
|
||||
composable.cameraConfig.value.retainViewOnReload = true
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
expect(mockLoad3d.setRetainViewOnReload).toHaveBeenCalledWith(true)
|
||||
expect(mockNode.properties['Camera Config']).toEqual({
|
||||
cameraType: 'orthographic',
|
||||
fov: 90,
|
||||
state: null,
|
||||
retainViewOnReload: true
|
||||
state: null
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -483,7 +483,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
load3d.setRetainViewOnReload(newValue.retainViewOnReload ?? false)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
|
||||
@@ -9,17 +9,21 @@ const {
|
||||
waitForLoad3dMock,
|
||||
onLoad3dReadyMock,
|
||||
configureMock,
|
||||
configureForSaveMeshMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock,
|
||||
getNodeByLocatorIdMock
|
||||
getNodeByLocatorIdMock,
|
||||
nodeToLoad3dMap
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
onLoad3dReadyMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
configureForSaveMeshMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn(),
|
||||
getNodeByLocatorIdMock: vi.fn()
|
||||
getNodeByLocatorIdMock: vi.fn(),
|
||||
nodeToLoad3dMap: new Map<object, unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
@@ -38,12 +42,13 @@ vi.mock('@/composables/useLoad3d', () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap: new Map()
|
||||
nodeToLoad3dMap
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
configureForSaveMesh = configureForSaveMeshMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -121,13 +126,15 @@ type ExtCreated = ComfyExtension & {
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +160,22 @@ function makePreview3DNode(
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makePreview3DAdvancedNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
@@ -178,7 +201,13 @@ interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
setCameraState: ReturnType<typeof vi.fn>
|
||||
getCameraState: ReturnType<typeof vi.fn>
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
getModelInfo: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
cameraManager: { perspectiveCamera: { fov: number } }
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
@@ -187,7 +216,13 @@ function makeLoad3dMock(): FakeLoad3d {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
getCameraState: vi.fn(() => ({ position: [0, 0, 5], target: [0, 0, 0] })),
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
applyModelTransform: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
cameraManager: { perspectiveCamera: { fov: 35 } },
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
@@ -198,6 +233,7 @@ async function flush() {
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
@@ -209,12 +245,14 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -609,3 +647,272 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3DAdvanced', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call configureForSaveMesh on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configureForSaveMesh when Last Time Model File is persisted', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
properties: { 'Last Time Model File': 'prev/model.glb' }
|
||||
})
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'prev/model.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches a camera-only serializeValue to the image widget', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[0].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = await widgets[0].serializeValue!()
|
||||
|
||||
expect(payload).toEqual({
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: { position: [0, 0, 5], target: [0, 0, 0] },
|
||||
recording: '',
|
||||
model_3d_info: []
|
||||
})
|
||||
})
|
||||
|
||||
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
const modelInfo = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
load3d.getModelInfo = vi.fn(() => modelInfo)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
const payload = (await widgets[0].serializeValue!()) as {
|
||||
model_3d_info: unknown[]
|
||||
}
|
||||
|
||||
expect(payload.model_3d_info).toEqual([modelInfo])
|
||||
})
|
||||
|
||||
it('onExecuted persists Last Time Model File with normalized slashes and calls configureForSaveMesh', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'sub/nested/mesh.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted applies the input cameraState when one is forwarded via PreviewUI3D', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', cameraState] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).toHaveBeenCalledWith(cameraState)
|
||||
})
|
||||
|
||||
it('onExecuted applies the first model_3d_info entry to the viewport when present', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, [transform]]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
})
|
||||
|
||||
it('onExecuted does not call applyModelTransform when model_3d_info is empty', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, []]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.applyModelTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted defensively skips cameraState apply when result[1] is missing', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb'] })
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted skips cameraState apply when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', { position: [1, 2, 3] }] })
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('onExecuted shows an error toast when no file path is returned', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode()
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('returns [] for non-Preview3DAdvanced nodes', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat models', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3DAdvanced' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DAdvancedExt.getNodeMenuItems(node)).toEqual([
|
||||
{ content: 'Export' }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,9 @@ type Matrix = number[][]
|
||||
type Load3dPreviewOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, string?, Matrix?, Matrix?]
|
||||
}>
|
||||
type Preview3DAdvancedOutput = NodeOutputWith<{
|
||||
result?: [string?, CameraState?, Model3DInfo?]
|
||||
}>
|
||||
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ComfyApp, app } from '@/scripts/app'
|
||||
@@ -267,40 +270,44 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
if (node.constructor.comfyClass === 'Load3D') {
|
||||
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||
|
||||
node.properties['Resource Folder'] = ''
|
||||
node.properties['Resource Folder'] = ''
|
||||
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
fileInput.onchange = async () => {
|
||||
await handleModelUpload(fileInput.files!, node)
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
|
||||
fileInput.click()
|
||||
})
|
||||
|
||||
const resourcesInput = createFileInput('*', true)
|
||||
|
||||
resourcesInput.onchange = async () => {
|
||||
await handleResourcesUpload(resourcesInput.files!, node)
|
||||
resourcesInput.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
node.addWidget(
|
||||
'button',
|
||||
'upload extra resources',
|
||||
'uploadExtraResources',
|
||||
() => {
|
||||
resourcesInput.click()
|
||||
}
|
||||
)
|
||||
|
||||
node.addWidget('button', 'clear', 'clear', () => {
|
||||
const modelWidget = node.widgets?.find(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node: node,
|
||||
@@ -645,3 +652,130 @@ useExtensionService().registerExtension({
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
load3d.setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -287,6 +287,41 @@ describe('GizmoManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyModelTransform', () => {
|
||||
it('sets position, quaternion, and scale on target and notifies', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyModelTransform({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.92 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
})
|
||||
|
||||
expect(model.position.x).toBeCloseTo(1)
|
||||
expect(model.position.y).toBeCloseTo(2)
|
||||
expect(model.position.z).toBeCloseTo(3)
|
||||
expect(model.quaternion.x).toBeCloseTo(0.1)
|
||||
expect(model.quaternion.y).toBeCloseTo(0.2)
|
||||
expect(model.quaternion.z).toBeCloseTo(0.3)
|
||||
expect(model.quaternion.w).toBeCloseTo(0.92)
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(onTransformChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() =>
|
||||
manager.applyModelTransform({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quaternion: { x: 0, y: 0, z: 0, w: 1 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransform', () => {
|
||||
it('returns current target transform', () => {
|
||||
manager.init()
|
||||
|
||||
@@ -159,6 +159,27 @@ export class GizmoManager {
|
||||
}
|
||||
}
|
||||
|
||||
applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.targetObject) return
|
||||
this.targetObject.position.set(
|
||||
transform.position.x,
|
||||
transform.position.y,
|
||||
transform.position.z
|
||||
)
|
||||
this.targetObject.quaternion.set(
|
||||
transform.quaternion.x,
|
||||
transform.quaternion.y,
|
||||
transform.quaternion.z,
|
||||
transform.quaternion.w
|
||||
)
|
||||
this.targetObject.scale.set(
|
||||
transform.scale.x,
|
||||
transform.scale.y,
|
||||
transform.scale.z
|
||||
)
|
||||
this.onTransformChange?.()
|
||||
}
|
||||
|
||||
getInitialTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -39,6 +39,7 @@ type GizmoStub = {
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
applyTransform: ReturnType<typeof vi.fn>
|
||||
applyModelTransform: ReturnType<typeof vi.fn>
|
||||
getTransform: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
updateCamera: ReturnType<typeof vi.fn>
|
||||
@@ -73,6 +74,7 @@ function makeGizmoStub(): GizmoStub {
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
applyModelTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
@@ -203,6 +205,19 @@ describe('Load3d', () => {
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
|
||||
})
|
||||
|
||||
it('applyModelTransform forwards the full position/quaternion/scale payload', () => {
|
||||
const transform = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
quaternion: { x: 0.1, y: 0.2, z: 0.3, w: 0.4 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
|
||||
ctx.load3d.applyModelTransform(transform)
|
||||
|
||||
expect(ctx.gizmo.applyModelTransform).toHaveBeenCalledWith(transform)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('getGizmoTransform returns the gizmoManager transform', () => {
|
||||
const transform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
@@ -772,8 +787,8 @@ describe('Load3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('retainViewOnReload', () => {
|
||||
function setupLoadInternal(initialFlag: boolean) {
|
||||
describe('camera framing across reloads', () => {
|
||||
function setupLoadInternal() {
|
||||
const getCameraState = vi.fn<() => CameraState>(() => ({
|
||||
position: new THREE.Vector3(1, 2, 3),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -802,25 +817,23 @@ describe('Load3d', () => {
|
||||
setupModelAnimations: vi.fn()
|
||||
},
|
||||
handleResize: vi.fn(),
|
||||
retainViewOnReload: initialFlag,
|
||||
hasLoadedModel: false
|
||||
})
|
||||
return { getCameraState, setCameraState, getCurrentCameraType }
|
||||
}
|
||||
|
||||
it('first load uses default framing even with retain enabled', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
it('first load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
|
||||
// hasLoadedModel started false, so retain shouldn't kick in yet.
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subsequent load captures camera state, skips reset, and restores it', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
it('subsequent load preserves the user-adjusted camera framing', async () => {
|
||||
const mocks = setupLoadInternal()
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -834,23 +847,8 @@ describe('Load3d', () => {
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not retain when the flag is off, even after a prior load', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
expect(mocks.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles to the saved camera type before restoring state when types differ', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
const mocks = setupLoadInternal()
|
||||
mocks.getCameraState.mockImplementation(() => ({
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(),
|
||||
@@ -870,7 +868,7 @@ describe('Load3d', () => {
|
||||
})
|
||||
|
||||
it('resets hasLoadedModel on clearModel so the next load uses default framing', async () => {
|
||||
const mocks = setupLoadInternal(true)
|
||||
const mocks = setupLoadInternal()
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.clearModel()
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
@@ -881,22 +879,6 @@ describe('Load3d', () => {
|
||||
expect(ctx.cameraManager.reset).toHaveBeenCalledOnce()
|
||||
expect(mocks.getCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setRetainViewOnReload flips the runtime behavior between loads', async () => {
|
||||
const mocks = setupLoadInternal(false)
|
||||
|
||||
await ctx.load3d.loadModel('a.glb')
|
||||
ctx.load3d.setRetainViewOnReload(true)
|
||||
;(ctx.cameraManager.reset as ReturnType<typeof vi.fn>).mockClear()
|
||||
mocks.getCameraState.mockClear()
|
||||
mocks.setCameraState.mockClear()
|
||||
|
||||
await ctx.load3d.loadModel('b.glb')
|
||||
|
||||
expect(ctx.cameraManager.reset).not.toHaveBeenCalled()
|
||||
expect(mocks.getCameraState).toHaveBeenCalledOnce()
|
||||
expect(mocks.setCameraState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
|
||||
@@ -105,7 +105,6 @@ class Load3d {
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
private retainViewOnReload: boolean = false
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
@@ -567,17 +566,14 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setRetainViewOnReload(value: boolean): void {
|
||||
this.retainViewOnReload = value
|
||||
}
|
||||
|
||||
private async _loadModelInternal(
|
||||
url: string,
|
||||
originalFileName?: string,
|
||||
options?: LoadModelOptions
|
||||
): Promise<void> {
|
||||
// First load always uses default framing; retain only applies on reload.
|
||||
const shouldRetainView = this.retainViewOnReload && this.hasLoadedModel
|
||||
// First load always uses default framing; subsequent reloads preserve
|
||||
// the user's framing.
|
||||
const shouldRetainView = this.hasLoadedModel
|
||||
const savedCameraState = shouldRetainView
|
||||
? this.cameraManager.getCameraState()
|
||||
: null
|
||||
@@ -907,6 +903,12 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public applyModelTransform(transform: Model3DTransform): void {
|
||||
if (!this.getCurrentModelCapabilities().gizmoTransform) return
|
||||
this.gizmoManager.applyModelTransform(transform)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getGizmoTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
|
||||
@@ -80,7 +80,6 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
retainViewOnReload?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "جاري إعادة تحميل النموذج...",
|
||||
"removeBackgroundImage": "إزالة صورة الخلفية",
|
||||
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
|
||||
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
|
||||
"scene": "المشهد",
|
||||
"showGrid": "عرض الشبكة",
|
||||
"showSkeleton": "إظهار الهيكل العظمي",
|
||||
|
||||
@@ -1956,7 +1956,6 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"retainViewOnReload": "Lock camera view across model reloads",
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "Recargando modelo...",
|
||||
"removeBackgroundImage": "Eliminar imagen de fondo",
|
||||
"resizeNodeMatchOutput": "Redimensionar nodo para coincidir con la salida",
|
||||
"retainViewOnReload": "Bloquear la vista de la cámara al recargar el modelo",
|
||||
"scene": "Escena",
|
||||
"showGrid": "Mostrar cuadrícula",
|
||||
"showSkeleton": "Mostrar esqueleto",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "در حال بارگذاری مجدد مدل...",
|
||||
"removeBackgroundImage": "حذف تصویر پسزمینه",
|
||||
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
|
||||
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
|
||||
"scene": "صحنه",
|
||||
"showGrid": "نمایش شبکه",
|
||||
"showSkeleton": "نمایش اسکلت",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "Rechargement du modèle...",
|
||||
"removeBackgroundImage": "Supprimer l'image de fond",
|
||||
"resizeNodeMatchOutput": "Redimensionner le nœud pour correspondre à la sortie",
|
||||
"retainViewOnReload": "Verrouiller la vue de la caméra lors du rechargement du modèle",
|
||||
"scene": "Scène",
|
||||
"showGrid": "Afficher la grille",
|
||||
"showSkeleton": "Afficher le squelette",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "モデルを再読み込み中...",
|
||||
"removeBackgroundImage": "背景画像を削除",
|
||||
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
|
||||
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
|
||||
"scene": "シーン",
|
||||
"showGrid": "グリッドを表示",
|
||||
"showSkeleton": "スケルトンを表示",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "모델 다시 로드 중...",
|
||||
"removeBackgroundImage": "배경 이미지 제거",
|
||||
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
|
||||
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
|
||||
"scene": "장면",
|
||||
"showGrid": "그리드 표시",
|
||||
"showSkeleton": "스켈레톤 표시",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "Recarregando modelo...",
|
||||
"removeBackgroundImage": "Remover Imagem de Fundo",
|
||||
"resizeNodeMatchOutput": "Redimensionar Nó para corresponder à saída",
|
||||
"retainViewOnReload": "Manter a visão da câmera ao recarregar o modelo",
|
||||
"scene": "Cena",
|
||||
"showGrid": "Mostrar Grade",
|
||||
"showSkeleton": "Mostrar Esqueleto",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "Перезагрузка модели...",
|
||||
"removeBackgroundImage": "Удалить фоновое изображение",
|
||||
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
|
||||
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
|
||||
"scene": "Сцена",
|
||||
"showGrid": "Показать сетку",
|
||||
"showSkeleton": "Показать скелет",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "Model yeniden yükleniyor...",
|
||||
"removeBackgroundImage": "Arka Plan Resmini Kaldır",
|
||||
"resizeNodeMatchOutput": "Düğümü çıktıya uyacak şekilde yeniden boyutlandır",
|
||||
"retainViewOnReload": "Model yeniden yüklendiğinde kamera görünümünü kilitle",
|
||||
"scene": "Sahne",
|
||||
"showGrid": "Izgarayı Göster",
|
||||
"showSkeleton": "İskeleti Göster",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "重新載入模型中...",
|
||||
"removeBackgroundImage": "移除背景圖片",
|
||||
"resizeNodeMatchOutput": "調整節點以符合輸出",
|
||||
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
|
||||
"scene": "場景",
|
||||
"showGrid": "顯示格線",
|
||||
"showSkeleton": "顯示骨架",
|
||||
|
||||
@@ -2047,7 +2047,6 @@
|
||||
"reloadingModel": "正在重新加载模型...",
|
||||
"removeBackgroundImage": "移除背景图片",
|
||||
"resizeNodeMatchOutput": "调整节点以匹配输出",
|
||||
"retainViewOnReload": "模型重新加载时锁定相机视角",
|
||||
"scene": "场景",
|
||||
"showGrid": "显示网格",
|
||||
"showSkeleton": "显示骨架",
|
||||
|
||||
Reference in New Issue
Block a user