add save option for 3d node on context menu (#6319)

## Summary

add save option for 3d node on context menu

## Changes
allow to export model from context menu, supported in
preview3d/load3d/saveMesh




https://github.com/user-attachments/assets/1f0f1a93-9cdb-477f-8bd3-e298c7e3892b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6319-add-save-option-for-3d-node-on-context-menu-2996d73d365081f7853cd2ae9c69fe4d)
by [Unito](https://www.unito.io)
This commit is contained in:
Terry Jia
2025-10-29 20:52:02 -04:00
committed by GitHub
parent 5e212156e1
commit c76f017f92
5 changed files with 148 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DAnimation from '@/components/load3d/Load3DAnimation.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { createExportMenuOptions } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
@@ -297,6 +298,8 @@ useExtensionService().registerExtension({
await nextTick()
useLoad3dService().waitForLoad3d(node, (load3d) => {
node.getExtraMenuOptions = createExportMenuOptions(load3d)
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
@@ -542,6 +545,8 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3dService().waitForLoad3d(node, (load3d) => {
node.getExtraMenuOptions = createExportMenuOptions(load3d)
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')

View File

@@ -1,6 +1,6 @@
import * as THREE from 'three'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { CameraManager } from './CameraManager'
@@ -22,6 +22,7 @@ import {
type MaterialMode,
type UpDirection
} from './interfaces'
import { app } from '@/scripts/app'
class Load3d {
renderer: THREE.WebGLRenderer
@@ -51,6 +52,13 @@ class Load3d {
targetAspectRatio: number = 1
isViewerMode: boolean = false
// Context menu tracking
private rightMouseDownX: number = 0
private rightMouseDownY: number = 0
private rightMouseMoved: boolean = false
private readonly dragThreshold: number = 5
private contextMenuAbortController: AbortController | null = null
constructor(
container: Element | HTMLElement,
options: Load3DOptions = {
@@ -164,6 +172,8 @@ class Load3d {
this.STATUS_MOUSE_ON_SCENE = false
this.STATUS_MOUSE_ON_VIEWER = false
this.initContextMenu()
this.handleResize()
this.startAnimation()
@@ -172,6 +182,65 @@ class Load3d {
}, 100)
}
/**
* Initialize context menu on the Three.js canvas
* Detects right-click vs right-drag to show menu only on click
*/
private initContextMenu(): void {
const canvas = this.renderer.domElement
this.contextMenuAbortController = new AbortController()
const { signal } = this.contextMenuAbortController
const mousedownHandler = (e: MouseEvent) => {
if (e.button === 2) {
this.rightMouseDownX = e.clientX
this.rightMouseDownY = e.clientY
this.rightMouseMoved = false
}
}
const mousemoveHandler = (e: MouseEvent) => {
if (e.buttons === 2) {
const dx = Math.abs(e.clientX - this.rightMouseDownX)
const dy = Math.abs(e.clientY - this.rightMouseDownY)
if (dx > this.dragThreshold || dy > this.dragThreshold) {
this.rightMouseMoved = true
}
}
}
const contextmenuHandler = (e: MouseEvent) => {
const wasDragging = this.rightMouseMoved
this.rightMouseMoved = false
if (wasDragging) {
return
}
e.preventDefault()
e.stopPropagation()
this.showNodeContextMenu(e)
}
canvas.addEventListener('mousedown', mousedownHandler, { signal })
canvas.addEventListener('mousemove', mousemoveHandler, { signal })
canvas.addEventListener('contextmenu', contextmenuHandler, { signal })
}
private showNodeContextMenu(event: MouseEvent): void {
const menuOptions = app.canvas.getNodeMenuOptions(this.node)
new LiteGraph.ContextMenu(menuOptions, {
event,
title: this.node.type,
extra: this.node
})
}
getEventManager(): EventManager {
return this.eventManager
}
@@ -621,6 +690,11 @@ class Load3d {
}
public remove(): void {
if (this.contextMenuAbortController) {
this.contextMenuAbortController.abort()
this.contextMenuAbortController = null
}
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {

View File

@@ -0,0 +1,62 @@
import { t } from '@/i18n'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import { useToastStore } from '@/platform/updates/common/toastStore'
import Load3d from '@/extensions/core/load3d/Load3d'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
const EXPORT_FORMATS = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
] as const
export function createExportMenuOptions(
load3d: Load3d
): (
canvas: LGraphCanvas,
options: (IContextMenuValue | null)[]
) => (IContextMenuValue | null)[] {
return function (
_canvas: LGraphCanvas,
options: (IContextMenuValue | null)[]
): (IContextMenuValue | null)[] {
options.push(null, {
content: 'Save',
has_submenu: true,
callback: (_value, _options, event, prev_menu) => {
const submenuOptions: IContextMenuValue[] = EXPORT_FORMATS.map(
(format) => ({
content: format.label,
callback: () => {
void (async () => {
try {
await load3d.exportModel(format.value)
useToastStore().add({
severity: 'success',
summary: t('toastMessages.exportSuccess', {
format: format.label
})
})
} catch (error) {
console.error('Export failed:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.label
})
)
}
})()
}
})
)
new LiteGraph.ContextMenu(submenuOptions, {
event,
parentMenu: prev_menu
})
}
})
return options
}
}

View File

@@ -1,6 +1,7 @@
import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import { createExportMenuOptions } from '@/extensions/core/load3d/exportMenuHelper'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { type CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
@@ -60,6 +61,10 @@ useExtensionService().registerExtension({
const load3d = useLoad3dService().getLoad3d(node)
if (load3d) {
node.getExtraMenuOptions = createExportMenuOptions(load3d)
}
const modelWidget = node.widgets?.find((w) => w.name === 'image')
if (load3d && modelWidget) {

View File

@@ -1472,6 +1472,7 @@
"failedToApplyTexture": "Failed to apply texture",
"no3dSceneToExport": "No 3D scene to export",
"failedToExportModel": "Failed to export model as {format}",
"exportSuccess": "Successfully exported model as {format}",
"fileLoadError": "Unable to find workflow in {fileName}",
"dropFileError": "Unable to process dropped item: {error}",
"interrupted": "Execution has been interrupted",