Compare commits

...

1 Commits

Author SHA1 Message Date
Terry Jia
0dec5aa6eb feat(load3d): add Preview3DAdvanced 2026-05-30 07:25:52 -04:00
23 changed files with 566 additions and 130 deletions

View File

@@ -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">

View File

@@ -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 = () => {

View File

@@ -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
})
})

View File

@@ -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 }

View File

@@ -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' }
])
})
})

View File

@@ -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
)
})
}
}
})
}
})

View File

@@ -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()

View File

@@ -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 }

View File

@@ -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', () => {

View File

@@ -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 }

View File

@@ -80,7 +80,6 @@ export interface CameraConfig {
cameraType: CameraType
fov: number
state?: CameraState
retainViewOnReload?: boolean
}
export interface LightConfig {

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "جاري إعادة تحميل النموذج...",
"removeBackgroundImage": "إزالة صورة الخلفية",
"resizeNodeMatchOutput": "تغيير حجم العقدة لتتناسب مع المخرج",
"retainViewOnReload": "تثبيت عرض الكاميرا عند إعادة تحميل النموذج",
"scene": "المشهد",
"showGrid": "عرض الشبكة",
"showSkeleton": "إظهار الهيكل العظمي",

View File

@@ -1956,7 +1956,6 @@
},
"load3d": {
"switchCamera": "Switch Camera",
"retainViewOnReload": "Lock camera view across model reloads",
"showGrid": "Show Grid",
"backgroundColor": "Background Color",
"lightIntensity": "Light Intensity",

View File

@@ -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",

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "در حال بارگذاری مجدد مدل...",
"removeBackgroundImage": "حذف تصویر پس‌زمینه",
"resizeNodeMatchOutput": "تغییر اندازه node مطابق خروجی",
"retainViewOnReload": "قفل کردن نمای دوربین هنگام بارگذاری مجدد مدل",
"scene": "صحنه",
"showGrid": "نمایش شبکه",
"showSkeleton": "نمایش اسکلت",

View File

@@ -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",

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "モデルを再読み込み中...",
"removeBackgroundImage": "背景画像を削除",
"resizeNodeMatchOutput": "ノードを出力に合わせてリサイズ",
"retainViewOnReload": "モデルの再読み込み時にカメラビューを固定する",
"scene": "シーン",
"showGrid": "グリッドを表示",
"showSkeleton": "スケルトンを表示",

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "모델 다시 로드 중...",
"removeBackgroundImage": "배경 이미지 제거",
"resizeNodeMatchOutput": "노드 크기를 출력에 맞추기",
"retainViewOnReload": "모델을 다시 불러와도 카메라 뷰 고정",
"scene": "장면",
"showGrid": "그리드 표시",
"showSkeleton": "스켈레톤 표시",

View File

@@ -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",

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "Перезагрузка модели...",
"removeBackgroundImage": "Удалить фоновое изображение",
"resizeNodeMatchOutput": "Изменить размер узла под вывод",
"retainViewOnReload": "Зафиксировать вид камеры при перезагрузке модели",
"scene": "Сцена",
"showGrid": "Показать сетку",
"showSkeleton": "Показать скелет",

View File

@@ -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",

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "重新載入模型中...",
"removeBackgroundImage": "移除背景圖片",
"resizeNodeMatchOutput": "調整節點以符合輸出",
"retainViewOnReload": "鎖定相機視角於模型重新載入時保持不變",
"scene": "場景",
"showGrid": "顯示格線",
"showSkeleton": "顯示骨架",

View File

@@ -2047,7 +2047,6 @@
"reloadingModel": "正在重新加载模型...",
"removeBackgroundImage": "移除背景图片",
"resizeNodeMatchOutput": "调整节点以匹配输出",
"retainViewOnReload": "模型重新加载时锁定相机视角",
"scene": "场景",
"showGrid": "显示网格",
"showSkeleton": "显示骨架",