mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-07 06:28:48 +00:00
Compare commits
21 Commits
glary/test
...
fix/subgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
824ff2b264 | ||
|
|
4b753407e5 | ||
|
|
744b6a8956 | ||
|
|
ebc5108d90 | ||
|
|
c5d7119ff2 | ||
|
|
40b8d4240c | ||
|
|
ef13872f63 | ||
|
|
66e75ed1e9 | ||
|
|
8eb2d1d078 | ||
|
|
a3893a593d | ||
|
|
deba72e7a0 | ||
|
|
3db0eac353 | ||
|
|
4c7729ee0b | ||
|
|
40083d593b | ||
|
|
7089a7d1a0 | ||
|
|
3b4811b00d | ||
|
|
b756545f59 | ||
|
|
da91bdc957 | ||
|
|
cf3006f82c | ||
|
|
be2d757c47 | ||
|
|
54f3127658 |
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
47
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -98,3 +98,50 @@ jobs:
|
||||
flags: e2e
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Generate HTML coverage report
|
||||
run: |
|
||||
if [ ! -s coverage/playwright/coverage.lcov ]; then
|
||||
echo "No coverage data; generating placeholder report."
|
||||
mkdir -p coverage/html
|
||||
echo '<html><body><h1>No E2E coverage data available for this run.</h1></body></html>' > coverage/html/index.html
|
||||
exit 0
|
||||
fi
|
||||
genhtml coverage/playwright/coverage.lcov \
|
||||
-o coverage/html \
|
||||
--title "ComfyUI E2E Coverage" \
|
||||
--no-function-coverage \
|
||||
--precision 1
|
||||
|
||||
- name: Upload HTML report artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html/
|
||||
retention-days: 30
|
||||
|
||||
deploy:
|
||||
needs: merge
|
||||
if: github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Download HTML report
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: e2e-coverage-html
|
||||
path: coverage/html
|
||||
|
||||
- name: Upload to GitHub Pages
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
|
||||
with:
|
||||
path: coverage/html
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 50],
|
||||
"size": [315, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "LATENT", "type": "LATENT", "links": [], "slot_index": 0 }
|
||||
],
|
||||
"properties": { "Node name for S&R": "KSampler" },
|
||||
"widgets_values": [42, "fixed", 20, 8, "euler", "normal", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Note",
|
||||
"pos": [50, 50],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["This is a reference note"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [50, 250],
|
||||
"size": [300, 150],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["# Markdown heading"],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] }
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
expect(switched).toBe(true)
|
||||
})
|
||||
|
||||
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
|
||||
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
|
||||
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting(settingId, !initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.LiteGraph!.middle_click_slot_add_default_node
|
||||
)
|
||||
)
|
||||
.toBe(!initialValue)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Dropdown setting can be changed and persists', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -34,10 +34,35 @@ export class Load3DHelper {
|
||||
return this.node.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
get gizmoToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Gizmo' })
|
||||
}
|
||||
|
||||
get gizmoTranslateButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Translate' })
|
||||
}
|
||||
|
||||
get gizmoRotateButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Rotate' })
|
||||
}
|
||||
|
||||
get gizmoScaleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Scale' })
|
||||
}
|
||||
|
||||
get gizmoResetButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Reset Transform' })
|
||||
}
|
||||
|
||||
async openMenu(): Promise<void> {
|
||||
await this.menuButton.click()
|
||||
}
|
||||
|
||||
async openGizmoCategory(): Promise<void> {
|
||||
await this.openMenu()
|
||||
await this.getMenuCategory('Gizmo').click()
|
||||
}
|
||||
|
||||
async setBackgroundColor(hex: string): Promise<void> {
|
||||
await this.colorInput.evaluate((el, value) => {
|
||||
;(el as HTMLInputElement).value = value
|
||||
|
||||
87
browser_tests/tests/load3d/gizmoControls.spec.ts
Normal file
87
browser_tests/tests/load3d/gizmoControls.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
const getGizmoConfig = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(1)
|
||||
const modelConfig = n?.properties?.['Model Config'] as
|
||||
| { gizmo?: { enabled: boolean; mode: string } }
|
||||
| undefined
|
||||
return modelConfig?.gizmo
|
||||
})
|
||||
|
||||
test.describe('Load3D Gizmo Controls', () => {
|
||||
test(
|
||||
'Gizmo category appears in the controls menu',
|
||||
{ tag: '@smoke' },
|
||||
async ({ load3d }) => {
|
||||
await load3d.openMenu()
|
||||
|
||||
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Selecting Gizmo category shows the toggle button',
|
||||
{ tag: '@smoke' },
|
||||
async ({ load3d }) => {
|
||||
await load3d.openGizmoCategory()
|
||||
|
||||
await expect(load3d.gizmoToggleButton).toBeVisible()
|
||||
await expect(load3d.gizmoTranslateButton).toBeHidden()
|
||||
await expect(load3d.gizmoRotateButton).toBeHidden()
|
||||
await expect(load3d.gizmoScaleButton).toBeHidden()
|
||||
await expect(load3d.gizmoResetButton).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Toggling gizmo reveals mode buttons and updates node state',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
await load3d.openGizmoCategory()
|
||||
await load3d.gizmoToggleButton.click()
|
||||
|
||||
await expect(load3d.gizmoTranslateButton).toBeVisible()
|
||||
await expect(load3d.gizmoRotateButton).toBeVisible()
|
||||
await expect(load3d.gizmoScaleButton).toBeVisible()
|
||||
await expect(load3d.gizmoResetButton).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled))
|
||||
.toBe(true)
|
||||
|
||||
await load3d.gizmoToggleButton.click()
|
||||
await expect(load3d.gizmoTranslateButton).toBeHidden()
|
||||
await expect
|
||||
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.enabled))
|
||||
.toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Selecting a gizmo mode updates node state',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
await load3d.openGizmoCategory()
|
||||
await load3d.gizmoToggleButton.click()
|
||||
|
||||
await load3d.gizmoRotateButton.click()
|
||||
await expect
|
||||
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
|
||||
.toBe('rotate')
|
||||
|
||||
await load3d.gizmoScaleButton.click()
|
||||
await expect
|
||||
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
|
||||
.toBe('scale')
|
||||
|
||||
await load3d.gizmoTranslateButton.click()
|
||||
await expect
|
||||
.poll(() => getGizmoConfig(comfyPage.page).then((g) => g?.mode))
|
||||
.toBe('translate')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Note Node API Export', { tag: '@node' }, () => {
|
||||
test('excludes Note and MarkdownNote from API format export', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const classTypes = Object.values(apiWorkflow).map((n) => n.class_type)
|
||||
expect(classTypes, 'API output should not contain Note').not.toContain(
|
||||
'Note'
|
||||
)
|
||||
expect(
|
||||
classTypes,
|
||||
'API output should not contain MarkdownNote'
|
||||
).not.toContain('MarkdownNote')
|
||||
expect(
|
||||
Object.keys(apiWorkflow),
|
||||
'All-virtual workflow should produce empty API output'
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('preserves real nodes while filtering virtual ones', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const apiWorkflow = await comfyPage.workflow.getExportedWorkflow({
|
||||
api: true
|
||||
})
|
||||
|
||||
const entries = Object.values(apiWorkflow)
|
||||
expect(entries, 'Exactly one real node in API output').toHaveLength(1)
|
||||
expect(entries[0].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
test('standard workflow export still includes Note nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
|
||||
const noteNodes = workflow.nodes.filter(
|
||||
(n) => n.type === 'Note' || n.type === 'MarkdownNote'
|
||||
)
|
||||
expect(
|
||||
noteNodes,
|
||||
'Standard export must preserve both Note and MarkdownNote'
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('no virtual node types leak through graphToPrompt', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/note_with_ksampler')
|
||||
|
||||
const virtualNodeCheck = await comfyPage.page.evaluate(async () => {
|
||||
const { output } = await window.app!.graphToPrompt()
|
||||
const virtualTypes = ['Note', 'MarkdownNote', 'Reroute', 'PrimitiveNode']
|
||||
const leaked: string[] = []
|
||||
for (const node of Object.values(output)) {
|
||||
if (virtualTypes.includes(node.class_type)) {
|
||||
leaked.push(node.class_type)
|
||||
}
|
||||
}
|
||||
return { leaked, totalNodes: Object.keys(output).length }
|
||||
})
|
||||
|
||||
expect(
|
||||
virtualNodeCheck.leaked,
|
||||
'No virtual node types should leak into API output'
|
||||
).toHaveLength(0)
|
||||
expect(virtualNodeCheck.totalNodes).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
143
browser_tests/tests/subgraphCopyPaste.spec.ts
Normal file
143
browser_tests/tests/subgraphCopyPaste.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
async function getSubgraphNodeIds(comfyPage: ComfyPage): Promise<string[]> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Subgraph Copy-Paste', { tag: ['@subgraph', '@widget'] }, () => {
|
||||
test('Copy-paste SubgraphNode preserves promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(originalPromoted).toContain('text')
|
||||
|
||||
// Select the subgraph node
|
||||
await originalNode.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Copy via Ctrl+C, then paste via Ctrl+V
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Should now have 2 subgraph nodes
|
||||
const nodeIds = await getSubgraphNodeIds(comfyPage)
|
||||
expect(nodeIds).toHaveLength(2)
|
||||
|
||||
// Both should have promoted widgets with 'text'
|
||||
for (const nodeId of nodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('Copy-paste SubgraphNode preserves proxyWidgets in serialized data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The pasted node should have proxyWidgets in its properties
|
||||
const nodeIds = await getSubgraphNodeIds(comfyPage)
|
||||
const pastedId = nodeIds.find((id) => id !== '11')
|
||||
expect(pastedId).toBeDefined()
|
||||
|
||||
const pastedProxyWidgets = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const pw = node?.properties?.proxyWidgets
|
||||
if (!Array.isArray(pw)) return []
|
||||
return pw as [string, string][]
|
||||
}, pastedId!)
|
||||
|
||||
expect(pastedProxyWidgets.length).toBeGreaterThan(0)
|
||||
|
||||
// The proxyWidgets should reference the 'text' widget
|
||||
const hasTextWidget = pastedProxyWidgets.some(
|
||||
([, widgetName]) => widgetName === 'text'
|
||||
)
|
||||
expect(hasTextWidget).toBe(true)
|
||||
})
|
||||
|
||||
test('Pasted SubgraphNode interior widget values survive round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'copy-paste-round-trip-test'
|
||||
|
||||
// Set a value on the promoted textarea
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.first().fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select and copy the SubgraphNode
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Serialize the whole graph and reload to test full round-trip
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
},
|
||||
serialized as Parameters<typeof comfyPage.page.evaluate>[1]
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Both subgraph nodes should still have promoted widgets
|
||||
const nodeIds = await getSubgraphNodeIds(comfyPage)
|
||||
expect(nodeIds.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const promoted = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promoted).toContain('text')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -167,7 +167,7 @@ test.describe('Image Crop', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
)
|
||||
|
||||
test(
|
||||
'Empty state matches screenshot baseline',
|
||||
'Empty state matches the screenshot baseline',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -4,7 +4,7 @@ Date: 2026-02-22
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
Accepted (Option A)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -42,4 +42,8 @@ Primitives act as a synchronization mechanism — no own state, just a projectio
|
||||
|
||||
## Decision
|
||||
|
||||
Pending. Option A is the most pragmatic first step. Option B can be revisited after Option A ships and stabilizes.
|
||||
Option A. Override `serialize()` on PrimitiveNode to preserve `widgets_values` through copy-paste. This is the lowest-risk fix with no change to connection lifecycle semantics.
|
||||
|
||||
Prerequisite: PR [#10010](https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010) replaced `clone().serialize()` with direct serialization in `_serializeItems`, eliminating the code path that dropped `widgets_values` for widget-less clones. Option A provides the PrimitiveNode-specific fallback for any remaining edge cases.
|
||||
|
||||
Option B can be revisited after Option A ships and stabilizes.
|
||||
|
||||
@@ -102,7 +102,6 @@
|
||||
"fuse.js": "^7.0.0",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"jsondiffpatch": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "catalog:",
|
||||
|
||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -267,9 +267,6 @@ catalogs:
|
||||
jsonata:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: ^0.7.3
|
||||
version: 0.7.3
|
||||
knip:
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
@@ -557,9 +554,6 @@ importers:
|
||||
jsonata:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0
|
||||
jsondiffpatch:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.3
|
||||
loglevel:
|
||||
specifier: ^1.9.2
|
||||
version: 1.9.2
|
||||
@@ -1780,9 +1774,6 @@ packages:
|
||||
'@cyberalien/svg-utils@1.1.1':
|
||||
resolution: {integrity: sha512-i05Cnpzeezf3eJAXLx7aFirTYYoq5D1XUItp1XsjqkerNJh//6BG9sOYHbiO7v0KYMvJAx3kosrZaRcNlQPdsA==}
|
||||
|
||||
'@dmsnell/diff-match-patch@1.1.0':
|
||||
resolution: {integrity: sha512-yejLPmM5pjsGvxS9gXablUSbInW7H976c/FJ4iQxWIm7/38xBySRemTPDe34lhg1gVLbJntX0+sH0jYfU+PN9A==}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1':
|
||||
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
|
||||
|
||||
@@ -7269,11 +7260,6 @@ packages:
|
||||
jsonc-parser@3.3.1:
|
||||
resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
|
||||
|
||||
jsondiffpatch@0.7.3:
|
||||
resolution: {integrity: sha512-zd4dqFiXSYyant2WgSXAZ9+yYqilNVvragVNkNRn2IFZKgjyULNrKRznqN4Zon0MkLueCg+3QaPVCnDAVP20OQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
@@ -11239,8 +11225,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@dmsnell/diff-match-patch@1.1.0': {}
|
||||
|
||||
'@dual-bundle/import-meta-resolve@4.2.1': {}
|
||||
|
||||
'@emmetio/abbreviation@2.3.3':
|
||||
@@ -17140,10 +17124,6 @@ snapshots:
|
||||
|
||||
jsonc-parser@3.3.1: {}
|
||||
|
||||
jsondiffpatch@0.7.3:
|
||||
dependencies:
|
||||
'@dmsnell/diff-match-patch': 1.1.0
|
||||
|
||||
jsonfile@6.2.0:
|
||||
dependencies:
|
||||
universalify: 2.0.1
|
||||
|
||||
@@ -90,7 +90,6 @@ catalog:
|
||||
jiti: 2.6.1
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^6.3.1
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
|
||||
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const TARGET = 80
|
||||
const MILESTONE_STEP = 5
|
||||
const MIN_DELTA = 0.05
|
||||
const BAR_WIDTH = 20
|
||||
|
||||
interface CoverageData {
|
||||
@@ -71,8 +72,9 @@ function formatPct(value: number): string {
|
||||
}
|
||||
|
||||
function formatDelta(delta: number): string {
|
||||
const sign = delta >= 0 ? '+' : ''
|
||||
return sign + delta.toFixed(1) + '%'
|
||||
const rounded = Math.abs(delta) < MIN_DELTA ? 0 : delta
|
||||
const sign = rounded >= 0 ? '+' : ''
|
||||
return sign + rounded.toFixed(1) + '%'
|
||||
}
|
||||
|
||||
function crossedMilestone(prev: number, curr: number): number | null {
|
||||
@@ -150,15 +152,18 @@ function main() {
|
||||
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
|
||||
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
|
||||
|
||||
const unitImproved =
|
||||
unitCurrent !== null &&
|
||||
unitBaseline !== null &&
|
||||
unitCurrent.percentage > unitBaseline.percentage
|
||||
const unitDelta =
|
||||
unitCurrent !== null && unitBaseline !== null
|
||||
? unitCurrent.percentage - unitBaseline.percentage
|
||||
: 0
|
||||
|
||||
const e2eImproved =
|
||||
e2eCurrent !== null &&
|
||||
e2eBaseline !== null &&
|
||||
e2eCurrent.percentage > e2eBaseline.percentage
|
||||
const e2eDelta =
|
||||
e2eCurrent !== null && e2eBaseline !== null
|
||||
? e2eCurrent.percentage - e2eBaseline.percentage
|
||||
: 0
|
||||
|
||||
const unitImproved = unitDelta >= MIN_DELTA
|
||||
const e2eImproved = e2eDelta >= MIN_DELTA
|
||||
|
||||
if (!unitImproved && !e2eImproved) {
|
||||
process.exit(0)
|
||||
@@ -172,12 +177,12 @@ function main() {
|
||||
)
|
||||
summaryLines.push('')
|
||||
|
||||
if (unitCurrent && unitBaseline) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
|
||||
if (unitImproved) {
|
||||
summaryLines.push(formatCoverageRow('Unit', unitCurrent!, unitBaseline!))
|
||||
}
|
||||
|
||||
if (e2eCurrent && e2eBaseline) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
|
||||
if (e2eImproved) {
|
||||
summaryLines.push(formatCoverageRow('E2E', e2eCurrent!, e2eBaseline!))
|
||||
}
|
||||
|
||||
summaryLines.push('')
|
||||
|
||||
@@ -405,8 +405,8 @@ import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -40,9 +43,27 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-auto absolute top-12 right-2 z-20">
|
||||
<div class="flex flex-col rounded-lg bg-backdrop/30">
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.fitToViewer')"
|
||||
@click="handleFitToViewer"
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="enable3DViewer && node"
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20"
|
||||
class="pointer-events-auto absolute top-24 right-2 z-20"
|
||||
>
|
||||
<ViewerControls :node="node as LGraphNode" />
|
||||
</div>
|
||||
@@ -51,8 +72,8 @@
|
||||
v-if="!isPreview"
|
||||
class="pointer-events-auto absolute right-2 z-20"
|
||||
:class="{
|
||||
'top-12': !enable3DViewer,
|
||||
'top-24': enable3DViewer
|
||||
'top-24': !enable3DViewer,
|
||||
'top-36': enable3DViewer
|
||||
}"
|
||||
>
|
||||
<RecordingControls
|
||||
@@ -77,6 +98,7 @@ import Load3DScene from '@/components/load3d/Load3DScene.vue'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -143,6 +165,10 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@
|
||||
v-if="showExportControls"
|
||||
@export-model="handleExportModel"
|
||||
/>
|
||||
|
||||
<GizmoControls
|
||||
v-if="showGizmoControls"
|
||||
v-model:gizmo-config="modelConfig!.gizmo"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -102,6 +110,7 @@ import { computed, ref } from 'vue'
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
|
||||
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
@@ -109,6 +118,7 @@ import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
@@ -148,6 +158,7 @@ const categoryLabels: Record<string, string> = {
|
||||
model: 'load3d.model',
|
||||
camera: 'load3d.camera',
|
||||
light: 'load3d.light',
|
||||
gizmo: 'load3d.gizmo.label',
|
||||
export: 'load3d.export'
|
||||
}
|
||||
|
||||
@@ -156,7 +167,7 @@ const availableCategories = computed(() => {
|
||||
return ['scene', 'model', 'camera']
|
||||
}
|
||||
|
||||
return ['scene', 'model', 'camera', 'light', 'export']
|
||||
return ['scene', 'model', 'camera', 'light', 'gizmo', 'export']
|
||||
})
|
||||
|
||||
const showSceneControls = computed(
|
||||
@@ -175,6 +186,9 @@ const showLightControls = computed(
|
||||
!!modelConfig.value
|
||||
)
|
||||
const showExportControls = computed(() => activeCategory.value === 'export')
|
||||
const showGizmoControls = computed(
|
||||
() => activeCategory.value === 'gizmo' && !!modelConfig.value
|
||||
)
|
||||
|
||||
const toggleMenu = () => {
|
||||
isMenuOpen.value = !isMenuOpen.value
|
||||
@@ -190,6 +204,7 @@ const categoryIcons = {
|
||||
model: 'icon-[lucide--box]',
|
||||
camera: 'icon-[lucide--camera]',
|
||||
light: 'icon-[lucide--sun]',
|
||||
gizmo: 'icon-[lucide--move-3d]',
|
||||
export: 'icon-[lucide--download]'
|
||||
} as const
|
||||
|
||||
@@ -205,6 +220,9 @@ const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const handleBackgroundImageUpdate = (file: File | null) => {
|
||||
@@ -218,4 +236,16 @@ const handleExportModel = (format: string) => {
|
||||
const handleHDRIFileUpdate = (file: File | null) => {
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
const handleToggleGizmo = (enabled: boolean) => {
|
||||
emit('toggleGizmo', enabled)
|
||||
}
|
||||
|
||||
const handleSetGizmoMode = (mode: GizmoMode) => {
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<GizmoControls
|
||||
v-model:gizmo-enabled="viewer.gizmoEnabled.value"
|
||||
v-model:gizmo-mode="viewer.gizmoMode.value"
|
||||
@reset-transform="viewer.resetGizmoTransform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!viewer.isSplatModel.value" class="space-y-4 p-2">
|
||||
<ExportControls @export-model="viewer.exportModel" />
|
||||
</div>
|
||||
@@ -99,6 +107,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import AnimationControls from '@/components/load3d/controls/AnimationControls.vue'
|
||||
import CameraControls from '@/components/load3d/controls/viewer/ViewerCameraControls.vue'
|
||||
import ExportControls from '@/components/load3d/controls/viewer/ViewerExportControls.vue'
|
||||
import GizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/viewer/ViewerLightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/viewer/ViewerModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/viewer/ViewerSceneControls.vue'
|
||||
|
||||
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
155
src/components/load3d/controls/GizmoControls.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GizmoControls from '@/components/load3d/controls/GizmoControls.vue'
|
||||
import type { GizmoConfig } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
gizmo: {
|
||||
toggle: 'Gizmo',
|
||||
translate: 'Translate',
|
||||
rotate: 'Rotate',
|
||||
scale: 'Scale',
|
||||
reset: 'Reset Transform'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<GizmoConfig> = {}): GizmoConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(initial: Partial<GizmoConfig> = {}) {
|
||||
const gizmoConfig = ref<GizmoConfig>(makeConfig(initial))
|
||||
|
||||
const utils = render(GizmoControls, {
|
||||
props: {
|
||||
gizmoConfig: gizmoConfig.value,
|
||||
'onUpdate:gizmoConfig': (v: GizmoConfig | undefined) => {
|
||||
if (v) gizmoConfig.value = v
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, gizmoConfig, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('GizmoControls', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders only the toggle button when gizmo is disabled', () => {
|
||||
renderComponent({ enabled: false })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Gizmo' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Rotate' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Scale' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Reset Transform' })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders mode and reset buttons when gizmo is enabled', () => {
|
||||
renderComponent({ enabled: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Translate' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Rotate' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Scale' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Reset Transform' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('flips enabled and emits toggleGizmo when the toggle is clicked', async () => {
|
||||
const { user, gizmoConfig, emitted } = renderComponent({ enabled: false })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
expect(gizmoConfig.value.enabled).toBe(true)
|
||||
expect(emitted().toggleGizmo).toEqual([[true]])
|
||||
})
|
||||
|
||||
it('turns off gizmo and emits false when toggled from enabled state', async () => {
|
||||
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
expect(gizmoConfig.value.enabled).toBe(false)
|
||||
expect(emitted().toggleGizmo).toEqual([[false]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Translate', 'translate'],
|
||||
['Rotate', 'rotate'],
|
||||
['Scale', 'scale']
|
||||
] as const)(
|
||||
'sets mode to %s and emits setGizmoMode when clicked',
|
||||
async (label, mode) => {
|
||||
const { user, gizmoConfig, emitted } = renderComponent({ enabled: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
|
||||
expect(gizmoConfig.value.mode).toBe(mode)
|
||||
expect(emitted().setGizmoMode).toEqual([[mode]])
|
||||
}
|
||||
)
|
||||
|
||||
it('emits resetGizmoTransform without mutating config on reset click', async () => {
|
||||
const { user, gizmoConfig, emitted } = renderComponent({
|
||||
enabled: true,
|
||||
mode: 'rotate'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset Transform' }))
|
||||
|
||||
expect(emitted().resetGizmoTransform).toEqual([[]])
|
||||
expect(gizmoConfig.value.mode).toBe('rotate')
|
||||
expect(gizmoConfig.value.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('highlights the active mode button with a ring', () => {
|
||||
renderComponent({ enabled: true, mode: 'rotate' })
|
||||
|
||||
const translate = screen.getByRole('button', { name: 'Translate' })
|
||||
const rotate = screen.getByRole('button', { name: 'Rotate' })
|
||||
const scale = screen.getByRole('button', { name: 'Scale' })
|
||||
|
||||
expect(rotate.className).toContain('ring-2')
|
||||
expect(translate.className).not.toContain('ring-2')
|
||||
expect(scale.className).not.toContain('ring-2')
|
||||
})
|
||||
|
||||
it('does nothing when clicked with no model value bound', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { emitted } = render(GizmoControls, {
|
||||
props: { gizmoConfig: undefined },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
expect(emitted().toggleGizmo).toBeUndefined()
|
||||
})
|
||||
})
|
||||
122
src/components/load3d/controls/GizmoControls.vue
Normal file
122
src/components/load3d/controls/GizmoControls.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Button
|
||||
v-tooltip.right="{ value: t('load3d.gizmo.toggle'), showDelay: 300 }"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="cn('rounded-full', gizmoEnabled && 'ring-2 ring-white/50')"
|
||||
:aria-label="t('load3d.gizmo.toggle')"
|
||||
@click="toggleGizmo"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.translate'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
gizmoMode === 'translate' && 'ring-2 ring-white/50'
|
||||
)
|
||||
"
|
||||
:aria-label="t('load3d.gizmo.translate')"
|
||||
@click="setMode('translate')"
|
||||
>
|
||||
<i class="pi pi-arrows-alt text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.rotate'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="
|
||||
cn('rounded-full', gizmoMode === 'rotate' && 'ring-2 ring-white/50')
|
||||
"
|
||||
:aria-label="t('load3d.gizmo.rotate')"
|
||||
@click="setMode('rotate')"
|
||||
>
|
||||
<i class="pi pi-sync text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.scale'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:class="
|
||||
cn('rounded-full', gizmoMode === 'scale' && 'ring-2 ring-white/50')
|
||||
"
|
||||
:aria-label="t('load3d.gizmo.scale')"
|
||||
@click="setMode('scale')"
|
||||
>
|
||||
<i class="pi pi-expand text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('load3d.gizmo.reset'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="t('load3d.gizmo.reset')"
|
||||
@click="resetTransform"
|
||||
>
|
||||
<i class="pi pi-refresh text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type {
|
||||
GizmoConfig,
|
||||
GizmoMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gizmoConfig = defineModel<GizmoConfig>('gizmoConfig')
|
||||
|
||||
const gizmoEnabled = computed(() => gizmoConfig.value?.enabled ?? false)
|
||||
const gizmoMode = computed(() => gizmoConfig.value?.mode ?? 'translate')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const toggleGizmo = () => {
|
||||
if (!gizmoConfig.value) return
|
||||
gizmoConfig.value.enabled = !gizmoConfig.value.enabled
|
||||
emit('toggleGizmo', gizmoConfig.value.enabled)
|
||||
}
|
||||
|
||||
const setMode = (mode: GizmoMode) => {
|
||||
if (!gizmoConfig.value) return
|
||||
gizmoConfig.value.mode = mode
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
const resetTransform = () => {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ViewerGizmoControls from '@/components/load3d/controls/viewer/ViewerGizmoControls.vue'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { on: 'On', off: 'Off' },
|
||||
load3d: {
|
||||
gizmo: {
|
||||
toggle: 'Gizmo',
|
||||
translate: 'Translate',
|
||||
rotate: 'Rotate',
|
||||
scale: 'Scale',
|
||||
reset: 'Reset Transform'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderComponent(
|
||||
initial: { enabled?: boolean; mode?: GizmoMode } = {}
|
||||
) {
|
||||
const enabled = ref<boolean>(initial.enabled ?? false)
|
||||
const mode = ref<GizmoMode>(initial.mode ?? 'translate')
|
||||
|
||||
const utils = render(ViewerGizmoControls, {
|
||||
props: {
|
||||
gizmoEnabled: enabled.value,
|
||||
'onUpdate:gizmoEnabled': (v: boolean | undefined) => {
|
||||
if (v !== undefined) enabled.value = v
|
||||
},
|
||||
gizmoMode: mode.value,
|
||||
'onUpdate:gizmoMode': (v: GizmoMode | undefined) => {
|
||||
if (v) mode.value = v
|
||||
}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, enabled, mode, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ViewerGizmoControls', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders only the on/off toggle when gizmo is disabled', () => {
|
||||
renderComponent({ enabled: false })
|
||||
|
||||
expect(screen.getByText('Gizmo')).toBeTruthy()
|
||||
expect(screen.getByText('Off')).toBeTruthy()
|
||||
expect(screen.getByText('On')).toBeTruthy()
|
||||
|
||||
expect(screen.queryByText('Translate')).toBeNull()
|
||||
expect(screen.queryByText('Rotate')).toBeNull()
|
||||
expect(screen.queryByText('Scale')).toBeNull()
|
||||
expect(screen.queryByText('Reset Transform')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders mode toggles and reset button when gizmo is enabled', () => {
|
||||
renderComponent({ enabled: true })
|
||||
|
||||
expect(screen.getByText('Translate')).toBeTruthy()
|
||||
expect(screen.getByText('Rotate')).toBeTruthy()
|
||||
expect(screen.getByText('Scale')).toBeTruthy()
|
||||
expect(screen.getByText('Reset Transform')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('enables gizmo when the On item is clicked', async () => {
|
||||
const { user, enabled } = renderComponent({ enabled: false })
|
||||
|
||||
await user.click(screen.getByText('On'))
|
||||
|
||||
expect(enabled.value).toBe(true)
|
||||
})
|
||||
|
||||
it('disables gizmo when the Off item is clicked from an enabled state', async () => {
|
||||
const { user, enabled } = renderComponent({ enabled: true })
|
||||
|
||||
await user.click(screen.getByText('Off'))
|
||||
|
||||
expect(enabled.value).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Translate', 'translate'],
|
||||
['Rotate', 'rotate'],
|
||||
['Scale', 'scale']
|
||||
] as const)(
|
||||
'updates mode to %s when its toggle item is clicked',
|
||||
async (label, expected) => {
|
||||
const { user, mode } = renderComponent({
|
||||
enabled: true,
|
||||
mode: 'translate'
|
||||
})
|
||||
|
||||
await user.click(screen.getByText(label))
|
||||
|
||||
expect(mode.value).toBe(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('emits reset-transform when the reset button is clicked', async () => {
|
||||
const { user, emitted } = renderComponent({
|
||||
enabled: true,
|
||||
mode: 'rotate'
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /reset transform/i }))
|
||||
|
||||
expect(emitted()['reset-transform']).toEqual([[]])
|
||||
})
|
||||
|
||||
it('leaves mode unchanged when deselecting the active mode', async () => {
|
||||
const { user, mode } = renderComponent({ enabled: true, mode: 'scale' })
|
||||
|
||||
await user.click(screen.getByText('Scale'))
|
||||
|
||||
expect(mode.value).toBe('scale')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<label>{{ $t('load3d.gizmo.toggle') }}</label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="gizmoEnabled ? 'on' : 'off'"
|
||||
@update:model-value="(v) => (gizmoEnabled = v === 'on')"
|
||||
>
|
||||
<ToggleGroupItem value="off" size="sm">
|
||||
{{ $t('g.off') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="on" size="sm">
|
||||
{{ $t('g.on') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
:model-value="gizmoMode"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
if (v) gizmoMode = v as GizmoMode
|
||||
}
|
||||
"
|
||||
>
|
||||
<ToggleGroupItem value="translate">
|
||||
{{ $t('load3d.gizmo.translate') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="rotate">
|
||||
{{ $t('load3d.gizmo.rotate') }}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="scale">
|
||||
{{ $t('load3d.gizmo.scale') }}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button variant="secondary" @click="$emit('reset-transform')">
|
||||
<i class="pi pi-refresh" />
|
||||
{{ $t('load3d.gizmo.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const gizmoEnabled = defineModel<boolean>('gizmoEnabled')
|
||||
const gizmoMode = defineModel<GizmoMode>('gizmoMode')
|
||||
|
||||
defineEmits<{
|
||||
(e: 'reset-transform'): void
|
||||
}>()
|
||||
</script>
|
||||
86
src/components/ui/button/Button.test.ts
Normal file
86
src/components/ui/button/Button.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Button from './Button.vue'
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders slot content inside a button by default', () => {
|
||||
render(Button, {
|
||||
slots: { default: 'Click me' }
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fires click events when enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(Button, {
|
||||
slots: { default: 'Click me' },
|
||||
attrs: { onClick }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Click me' }))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hides slot content, shows a spinner, and disables the button while loading', () => {
|
||||
const { container } = render(Button, {
|
||||
props: { loading: true },
|
||||
slots: { default: 'Submit' }
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Submit')).not.toBeInTheDocument()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- PrimeVue spinner icon has no accessible role
|
||||
expect(container.querySelector('.pi-spin')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not fire click when loading', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(Button, {
|
||||
props: { loading: true },
|
||||
attrs: { onClick }
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables the button when disabled prop is true', () => {
|
||||
render(Button, {
|
||||
props: { disabled: true },
|
||||
slots: { default: 'Nope' }
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Nope' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders as an anchor when as="a"', () => {
|
||||
const { container } = render(Button, {
|
||||
props: { as: 'a' },
|
||||
slots: { default: 'Link' }
|
||||
})
|
||||
|
||||
// eslint-disable-next-line testing-library/no-node-access -- root element tag is the contract under test
|
||||
const root = container.firstElementChild
|
||||
expect(root?.tagName).toBe('A')
|
||||
})
|
||||
|
||||
it('applies variant classes through buttonVariants', () => {
|
||||
render(Button, {
|
||||
props: { variant: 'primary' },
|
||||
slots: { default: 'Primary' }
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Primary' })).toHaveClass(
|
||||
'bg-primary-background'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
const meta: Meta<typeof MultiSelect> = {
|
||||
title: 'Components/Select/MultiSelect',
|
||||
@@ -155,9 +155,6 @@ import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import {
|
||||
selectContentClass,
|
||||
selectDropdownClass,
|
||||
@@ -165,8 +162,10 @@ import {
|
||||
selectItemVariants,
|
||||
selectTriggerVariants,
|
||||
stopEscapeToDocument
|
||||
} from './select.variants'
|
||||
import type { SelectOption } from './types'
|
||||
} from '@/components/ui/select/select.variants'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MultiSelect from './MultiSelect.vue'
|
||||
import SingleSelect from './SingleSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
|
||||
import type { SelectOption } from './types'
|
||||
|
||||
const meta: Meta = {
|
||||
@@ -84,17 +84,16 @@ import {
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import {
|
||||
selectContentClass,
|
||||
selectDropdownClass,
|
||||
selectItemVariants,
|
||||
selectTriggerVariants,
|
||||
stopEscapeToDocument
|
||||
} from './select.variants'
|
||||
import type { SelectOption } from './types'
|
||||
} from '@/components/ui/select/select.variants'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import { usePopoverSizing } from '@/composables/usePopoverSizing'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
141
src/components/ui/slider/Slider.test.ts
Normal file
141
src/components/ui/slider/Slider.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import Slider from './Slider.vue'
|
||||
|
||||
async function flush() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('Slider', () => {
|
||||
it('renders a single thumb with role="slider" for a single-value model', async () => {
|
||||
render(Slider, { props: { modelValue: [50] } })
|
||||
await flush()
|
||||
|
||||
const thumbs = screen.getAllByRole('slider')
|
||||
expect(thumbs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders one thumb per value for a range model', async () => {
|
||||
render(Slider, { props: { modelValue: [20, 50] } })
|
||||
await flush()
|
||||
|
||||
const thumbs = screen.getAllByRole('slider')
|
||||
expect(thumbs).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('exposes min/max/step via ARIA on the thumb', async () => {
|
||||
render(Slider, {
|
||||
props: { modelValue: [10], min: 0, max: 200, step: 5 }
|
||||
})
|
||||
await flush()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '200')
|
||||
expect(thumb).toHaveAttribute('aria-valuenow', '10')
|
||||
})
|
||||
|
||||
it('emits update:modelValue with an increased value on ArrowRight', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const latest = onUpdate.mock.calls.at(-1)?.[0]
|
||||
expect(latest?.[0]).toBeGreaterThan(50)
|
||||
})
|
||||
|
||||
it('emits update:modelValue with a decreased value on ArrowLeft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowLeft}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const latest = onUpdate.mock.calls.at(-1)?.[0]
|
||||
expect(latest?.[0]).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('respects step size when emitting updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: number[] | undefined) => void>()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith([60])
|
||||
})
|
||||
|
||||
it('marks the root as disabled when disabled prop is set', async () => {
|
||||
const { container } = render(Slider, {
|
||||
props: { modelValue: [30], disabled: true }
|
||||
})
|
||||
await flush()
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Reka exposes disabled state as a data attribute on the root
|
||||
const root = container.querySelector('[data-slot="slider"]')
|
||||
expect(root).toHaveAttribute('data-disabled')
|
||||
})
|
||||
|
||||
it('does not emit updates via keyboard when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(Slider, {
|
||||
props: {
|
||||
modelValue: [50],
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: true,
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
await flush()
|
||||
|
||||
screen.getByRole('slider').focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
71
src/components/ui/textarea/Textarea.test.ts
Normal file
71
src/components/ui/textarea/Textarea.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Textarea from './Textarea.vue'
|
||||
|
||||
describe('Textarea', () => {
|
||||
it('renders a textarea element', () => {
|
||||
render(Textarea)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInstanceOf(HTMLTextAreaElement)
|
||||
})
|
||||
|
||||
it('populates the textarea with the initial v-model value', () => {
|
||||
render(Textarea, { props: { modelValue: 'initial text' } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('initial text')
|
||||
})
|
||||
|
||||
it('emits update:modelValue as the user types', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn<(value: string | number | undefined) => void>()
|
||||
|
||||
render(Textarea, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
}
|
||||
})
|
||||
|
||||
await user.type(screen.getByRole('textbox'), 'hi')
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
expect(onUpdate.mock.calls.at(-1)?.[0]).toBe('hi')
|
||||
})
|
||||
|
||||
it('forwards placeholder and rows attrs to the native textarea', () => {
|
||||
render(Textarea, {
|
||||
attrs: { placeholder: 'Write something', rows: 6 }
|
||||
})
|
||||
|
||||
const textarea = screen.getByPlaceholderText('Write something')
|
||||
expect(textarea).toHaveAttribute('rows', '6')
|
||||
})
|
||||
|
||||
it('does not accept typed input when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onUpdate = vi.fn()
|
||||
|
||||
render(Textarea, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
attrs: { disabled: true }
|
||||
})
|
||||
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toBeDisabled()
|
||||
await user.type(textarea, 'blocked')
|
||||
|
||||
expect(onUpdate).not.toHaveBeenCalled()
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('forwards custom class alongside internal classes', () => {
|
||||
render(Textarea, { props: { class: 'custom-extra-class' } })
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveClass('custom-extra-class')
|
||||
})
|
||||
})
|
||||
@@ -131,8 +131,8 @@ import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
|
||||
@@ -7,9 +7,9 @@ import CardBottom from '@/components/card/CardBottom.vue'
|
||||
import CardContainer from '@/components/card/CardContainer.vue'
|
||||
import CardTop from '@/components/card/CardTop.vue'
|
||||
import Tag from '@/components/chip/Tag.vue'
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
@@ -146,6 +146,12 @@ describe('useLoad3d', () => {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
setGizmoEnabled: vi.fn(),
|
||||
setGizmoMode: vi.fn(),
|
||||
resetGizmoTransform: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
fitToViewer: vi.fn(),
|
||||
setAnimationTime: vi.fn(),
|
||||
renderer: {
|
||||
domElement: mockCanvas
|
||||
} as Partial<Load3d['renderer']> as Load3d['renderer']
|
||||
@@ -169,38 +175,6 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value).toEqual({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
expect(composable.modelConfig.value).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
})
|
||||
expect(composable.cameraConfig.value).toEqual({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
})
|
||||
expect(composable.lightConfig.value).toEqual({
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.hasRecording.value).toBe(false)
|
||||
expect(composable.loading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('should initialize Load3d with container and node', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -229,8 +203,6 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(true)
|
||||
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#000000')
|
||||
expect(mockLoad3d.setBackgroundRenderMode).toHaveBeenCalledWith('tiled')
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('original')
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('original')
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('perspective')
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(75)
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(5)
|
||||
@@ -271,53 +243,29 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('should load model if model_file widget exists', async () => {
|
||||
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'subfolder',
|
||||
'test.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
expect(nodeToLoad3dMap.has(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('should restore camera state after loading model', async () => {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
;(mockNode.properties!['Camera Config'] as { state: unknown }).state = {
|
||||
it('should restore camera config from node properties', async () => {
|
||||
;(
|
||||
mockNode.properties!['Camera Config'] as Record<string, unknown>
|
||||
).state = {
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
}
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'subfolder',
|
||||
'test.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -325,7 +273,7 @@ describe('useLoad3d', () => {
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setCameraState).toHaveBeenCalledWith({
|
||||
expect(composable.cameraConfig.value.state).toEqual({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
@@ -460,11 +408,13 @@ describe('useLoad3d', () => {
|
||||
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(mockNode.properties['Model Config']).toEqual({
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: false
|
||||
})
|
||||
const savedModelConfig = mockNode.properties['Model Config'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
expect(savedModelConfig.upDirection).toBe('+y')
|
||||
expect(savedModelConfig.materialMode).toBe('wireframe')
|
||||
expect(savedModelConfig.showSkeleton).toBe(false)
|
||||
})
|
||||
|
||||
it('should update camera config when values change', async () => {
|
||||
@@ -862,79 +812,72 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getModelUrl', () => {
|
||||
it('should handle http URLs directly', async () => {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'http://example.com/model.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://example.com/model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('should construct URL for local files', async () => {
|
||||
mockNode.widgets!.push({
|
||||
name: 'model_file',
|
||||
value: 'models/test.glb',
|
||||
type: 'text'
|
||||
} as IWidget)
|
||||
describe('handleModelDrop', () => {
|
||||
it('should upload file, construct URL, and load model', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'models',
|
||||
'test.glb'
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/models/test.glb'
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/models/test.glb'
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3dUtils.splitFilePath).toHaveBeenCalledWith('models/test.glb')
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'models',
|
||||
'test.glb',
|
||||
'input'
|
||||
)
|
||||
expect(api.apiURL).toHaveBeenCalledWith('/api/view/models/test.glb')
|
||||
const file = new File([''], 'model.glb', {
|
||||
type: 'model/gltf-binary'
|
||||
})
|
||||
await composable.handleModelDrop(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
|
||||
expect(mockLoad3d.loadModel).toHaveBeenCalledWith(
|
||||
'http://localhost/api/view/models/test.glb'
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
})
|
||||
|
||||
it('should use output type for preview mode', async () => {
|
||||
mockNode.widgets = [
|
||||
{ name: 'model_file', value: 'test.glb', type: 'text' } as IWidget
|
||||
] // No width/height widgets
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'test.glb'])
|
||||
it('should use resource folder for upload subfolder', async () => {
|
||||
mockNode.properties['Resource Folder'] = 'subfolder'
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('uploaded/model.glb')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue([
|
||||
'uploaded',
|
||||
'model.glb'
|
||||
])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/api/view/test.glb'
|
||||
'/api/view/uploaded/model.glb'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/api/view/test.glb'
|
||||
'http://localhost/api/view/uploaded/model.glb'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(Load3dUtils.getResourceURL).toHaveBeenCalledWith(
|
||||
'',
|
||||
'test.glb',
|
||||
'output'
|
||||
const file = new File([''], 'model.glb', {
|
||||
type: 'model/gltf-binary'
|
||||
})
|
||||
await composable.handleModelDrop(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d/subfolder')
|
||||
})
|
||||
|
||||
it('should not load model when load3d is not initialized', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
const file = new File([''], 'model.glb', {
|
||||
type: 'model/gltf-binary'
|
||||
})
|
||||
await composable.handleModelDrop(file)
|
||||
|
||||
expect(mockLoad3d.loadModel).not.toHaveBeenCalled()
|
||||
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
|
||||
'toastMessages.no3dScene'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1071,4 +1014,241 @@ describe('useLoad3d', () => {
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('existing.jpg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gizmo controls', () => {
|
||||
it('should include default gizmo config in modelConfig', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.modelConfig.value.gizmo).toEqual({
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('should restore gizmo config from node properties', async () => {
|
||||
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
|
||||
{
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.modelConfig.value.gizmo).toEqual({
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
})
|
||||
})
|
||||
|
||||
it('should add default gizmo config when missing from saved config', async () => {
|
||||
mockNode.properties!['Model Config'] = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.modelConfig.value.gizmo).toBeDefined()
|
||||
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should add default scale when gizmo config lacks scale', async () => {
|
||||
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
|
||||
{
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 }
|
||||
}
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
|
||||
x: 1,
|
||||
y: 1,
|
||||
z: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('handleToggleGizmo should enable gizmo and update config', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleToggleGizmo(true)
|
||||
|
||||
expect(mockLoad3d.setGizmoEnabled).toHaveBeenCalledWith(true)
|
||||
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('handleToggleGizmo should disable gizmo and update config', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleToggleGizmo(true)
|
||||
composable.handleToggleGizmo(false)
|
||||
|
||||
expect(mockLoad3d.setGizmoEnabled).toHaveBeenLastCalledWith(false)
|
||||
expect(composable.modelConfig.value.gizmo!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('handleSetGizmoMode should set mode and update config', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleSetGizmoMode('rotate')
|
||||
|
||||
expect(mockLoad3d.setGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
|
||||
})
|
||||
|
||||
it('handleResetGizmoTransform should call resetGizmoTransform', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleResetGizmoTransform()
|
||||
|
||||
expect(mockLoad3d.resetGizmoTransform).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should persist gizmo config to node properties via modelConfig watcher', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleToggleGizmo(true)
|
||||
composable.handleSetGizmoMode('rotate')
|
||||
await nextTick()
|
||||
|
||||
const savedConfig = mockNode.properties['Model Config'] as {
|
||||
gizmo: { enabled: boolean; mode: string }
|
||||
}
|
||||
expect(savedConfig.gizmo.enabled).toBe(true)
|
||||
expect(savedConfig.gizmo.mode).toBe('rotate')
|
||||
})
|
||||
|
||||
it('should register gizmoTransformChange event handler', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const gizmoEventCall = addEventCalls.find(
|
||||
([event]) => event === 'gizmoTransformChange'
|
||||
)
|
||||
expect(gizmoEventCall).toBeDefined()
|
||||
})
|
||||
|
||||
it('gizmoTransformChange event should update modelConfig', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const gizmoEventCall = addEventCalls.find(
|
||||
([event]) => event === 'gizmoTransformChange'
|
||||
)
|
||||
const handler = gizmoEventCall![1] as (data: unknown) => void
|
||||
|
||||
handler({
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 0.5, y: 0.6, z: 0.7 },
|
||||
scale: { x: 3, y: 3, z: 3 },
|
||||
enabled: true,
|
||||
mode: 'rotate'
|
||||
})
|
||||
|
||||
expect(composable.modelConfig.value.gizmo!.position).toEqual({
|
||||
x: 5,
|
||||
y: 6,
|
||||
z: 7
|
||||
})
|
||||
expect(composable.modelConfig.value.gizmo!.rotation).toEqual({
|
||||
x: 0.5,
|
||||
y: 0.6,
|
||||
z: 0.7
|
||||
})
|
||||
expect(composable.modelConfig.value.gizmo!.scale).toEqual({
|
||||
x: 3,
|
||||
y: 3,
|
||||
z: 3
|
||||
})
|
||||
expect(composable.modelConfig.value.gizmo!.enabled).toBe(true)
|
||||
expect(composable.modelConfig.value.gizmo!.mode).toBe('rotate')
|
||||
})
|
||||
|
||||
it('should reset gizmo config on model switch (not first load)', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.handleToggleGizmo(true)
|
||||
composable.handleSetGizmoMode('rotate')
|
||||
|
||||
const addEventCalls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const loadingStartCall = addEventCalls.find(
|
||||
([event]) => event === 'modelLoadingStart'
|
||||
)
|
||||
const loadingStartHandler = loadingStartCall![1] as () => void
|
||||
|
||||
const loadingEndCall = addEventCalls.find(
|
||||
([event]) => event === 'modelLoadingEnd'
|
||||
)
|
||||
const loadingEndHandler = loadingEndCall![1] as () => void
|
||||
loadingEndHandler()
|
||||
|
||||
loadingStartHandler()
|
||||
|
||||
expect(composable.modelConfig.value.gizmo).toEqual({
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('should not call gizmo methods when load3d is not initialized', () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
// These should not throw
|
||||
composable.handleToggleGizmo(true)
|
||||
composable.handleSetGizmoMode('rotate')
|
||||
composable.handleResetGizmoTransform()
|
||||
|
||||
expect(mockLoad3d.setGizmoEnabled).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.setGizmoMode).not.toHaveBeenCalled()
|
||||
expect(mockLoad3d.resetGizmoTransform).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MaybeRef } from 'vue'
|
||||
|
||||
import { toRef } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
CameraState,
|
||||
CameraType,
|
||||
EventCallback,
|
||||
GizmoConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
@@ -38,6 +40,7 @@ const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const nodeRef = toRef(nodeOrRef)
|
||||
let load3d: Load3d | null = null
|
||||
let isFirstModelLoad = true
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
@@ -49,7 +52,14 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const modelConfig = ref<ModelConfig>({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
const hasSkeleton = ref(false)
|
||||
@@ -183,11 +193,24 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const savedModelConfig = node.properties['Model Config'] as ModelConfig
|
||||
if (savedModelConfig) {
|
||||
modelConfig.value = savedModelConfig
|
||||
modelConfig.value = {
|
||||
...savedModelConfig,
|
||||
gizmo: savedModelConfig.gizmo
|
||||
? {
|
||||
...savedModelConfig.gizmo,
|
||||
scale: savedModelConfig.gizmo.scale ?? { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const savedCameraConfig = node.properties['Camera Config'] as CameraConfig
|
||||
const cameraStateToRestore = savedCameraConfig?.state
|
||||
|
||||
if (savedCameraConfig) {
|
||||
cameraConfig.value = savedCameraConfig
|
||||
@@ -235,31 +258,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget?.value) {
|
||||
const modelUrl = getModelUrl(modelWidget.value as string)
|
||||
if (modelUrl) {
|
||||
loading.value = true
|
||||
loadingMessage.value = t('load3d.reloadingModel')
|
||||
try {
|
||||
await load3d.loadModel(modelUrl)
|
||||
|
||||
if (cameraStateToRestore) {
|
||||
await nextTick()
|
||||
load3d.setCameraState(cameraStateToRestore)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reload model:', error)
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMessage.value = ''
|
||||
}
|
||||
}
|
||||
} else if (cameraStateToRestore) {
|
||||
load3d.setCameraState(cameraStateToRestore)
|
||||
}
|
||||
|
||||
applySceneConfigToLoad3d()
|
||||
applyLightConfigToLoad3d()
|
||||
}
|
||||
@@ -276,6 +274,31 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const applyGizmoConfigToLoad3d = () => {
|
||||
if (!load3d) return
|
||||
const gizmo = modelConfig.value.gizmo
|
||||
if (!gizmo) return
|
||||
const hasTransform =
|
||||
gizmo.position.x !== 0 ||
|
||||
gizmo.position.y !== 0 ||
|
||||
gizmo.position.z !== 0 ||
|
||||
gizmo.rotation.x !== 0 ||
|
||||
gizmo.rotation.y !== 0 ||
|
||||
gizmo.rotation.z !== 0 ||
|
||||
gizmo.scale.x !== 1 ||
|
||||
gizmo.scale.y !== 1 ||
|
||||
gizmo.scale.z !== 1
|
||||
if (hasTransform) {
|
||||
load3d.applyGizmoTransform(gizmo.position, gizmo.rotation, gizmo.scale)
|
||||
}
|
||||
if (gizmo.enabled) {
|
||||
load3d.setGizmoEnabled(true)
|
||||
}
|
||||
if (gizmo.mode !== 'translate') {
|
||||
load3d.setGizmoMode(gizmo.mode)
|
||||
}
|
||||
}
|
||||
|
||||
const applyLightConfigToLoad3d = () => {
|
||||
if (!load3d) return
|
||||
const cfg = lightConfig.value
|
||||
@@ -294,29 +317,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getModelUrl = (modelPath: string): string | null => {
|
||||
if (!modelPath) return null
|
||||
|
||||
try {
|
||||
if (modelPath.startsWith('http')) {
|
||||
return modelPath
|
||||
}
|
||||
|
||||
const trimmed = modelPath.trim()
|
||||
const hasOutputSuffix = trimmed.endsWith('[output]')
|
||||
const cleanPath = hasOutputSuffix
|
||||
? trimmed.replace(/\s*\[output\]$/, '')
|
||||
: trimmed
|
||||
const type = hasOutputSuffix || isPreview.value ? 'output' : 'input'
|
||||
|
||||
const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath)
|
||||
return api.apiURL(Load3dUtils.getResourceURL(subfolder, filename, type))
|
||||
} catch (error) {
|
||||
console.error('Failed to construct model URL:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const waitForLoad3d = (callback: Load3dReadyCallback) => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (!rawNode) return
|
||||
@@ -380,16 +380,34 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
watch(
|
||||
modelConfig,
|
||||
(newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
load3d.setUpDirection(newValue.upDirection)
|
||||
load3d.setMaterialMode(newValue.materialMode)
|
||||
load3d.setShowSkeleton(newValue.showSkeleton)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => modelConfig.value.upDirection,
|
||||
(newValue) => {
|
||||
if (load3d) load3d.setUpDirection(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => modelConfig.value.materialMode,
|
||||
(newValue) => {
|
||||
if (load3d) load3d.setMaterialMode(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => modelConfig.value.showSkeleton,
|
||||
(newValue) => {
|
||||
if (load3d) load3d.setShowSkeleton(newValue)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
cameraConfig,
|
||||
(newValue) => {
|
||||
@@ -741,6 +759,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
modelLoadingStart: () => {
|
||||
loadingMessage.value = t('load3d.loadingModel')
|
||||
loading.value = true
|
||||
if (!isFirstModelLoad) {
|
||||
modelConfig.value = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modelLoadingEnd: () => {
|
||||
loadingMessage.value = ''
|
||||
@@ -748,8 +780,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isSplatModel.value = load3d?.isSplatModel() ?? false
|
||||
isPlyModel.value = load3d?.isPlyModel() ?? false
|
||||
hasSkeleton.value = load3d?.hasSkeleton() ?? false
|
||||
// Reset skeleton visibility when loading new model
|
||||
modelConfig.value.showSkeleton = false
|
||||
applyGizmoConfigToLoad3d()
|
||||
isFirstModelLoad = false
|
||||
|
||||
if (load3d && isAssetPreviewSupported()) {
|
||||
const node = nodeRef.value
|
||||
@@ -816,9 +848,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
gizmoTransformChange: (data: GizmoConfig) => {
|
||||
if (modelConfig.value.gizmo && nodeRef.value) {
|
||||
modelConfig.value.gizmo.position = data.position
|
||||
modelConfig.value.gizmo.rotation = data.rotation
|
||||
modelConfig.value.gizmo.scale = data.scale
|
||||
modelConfig.value.gizmo.enabled = data.enabled
|
||||
modelConfig.value.gizmo.mode = data.mode
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
const handleToggleGizmo = (enabled: boolean) => {
|
||||
if (load3d && modelConfig.value.gizmo) {
|
||||
modelConfig.value.gizmo.enabled = enabled
|
||||
load3d.setGizmoEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetGizmoMode = (mode: GizmoMode) => {
|
||||
if (load3d && modelConfig.value.gizmo) {
|
||||
modelConfig.value.gizmo.mode = mode
|
||||
load3d.setGizmoMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFitToViewer = () => {
|
||||
if (load3d) {
|
||||
load3d.fitToViewer()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
if (load3d) {
|
||||
load3d.resetGizmoTransform()
|
||||
}
|
||||
}
|
||||
|
||||
const handleEvents = (action: 'add' | 'remove') => {
|
||||
Object.entries(eventConfig).forEach(([event, handler]) => {
|
||||
const method = `${action}EventListener` as const
|
||||
@@ -878,6 +945,10 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +110,15 @@ describe('useLoad3dViewer', () => {
|
||||
addEventListener: vi.fn(),
|
||||
hasAnimations: vi.fn().mockReturnValue(false),
|
||||
isSplatModel: vi.fn().mockReturnValue(false),
|
||||
isPlyModel: vi.fn().mockReturnValue(false)
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
setGizmoEnabled: vi.fn(),
|
||||
setGizmoMode: vi.fn(),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
getGizmoTransform: vi.fn().mockReturnValue({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
mockSourceLoad3d = {
|
||||
@@ -163,20 +171,6 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
|
||||
expect(viewer.backgroundColor.value).toBe('')
|
||||
expect(viewer.showGrid.value).toBe(true)
|
||||
expect(viewer.cameraType.value).toBe('perspective')
|
||||
expect(viewer.fov.value).toBe(75)
|
||||
expect(viewer.lightIntensity.value).toBe(1)
|
||||
expect(viewer.backgroundImage.value).toBe('')
|
||||
expect(viewer.hasBackgroundImage.value).toBe(false)
|
||||
expect(viewer.upDirection.value).toBe('original')
|
||||
expect(viewer.materialMode.value).toBe('original')
|
||||
})
|
||||
|
||||
it('should initialize viewer with source Load3d state', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
@@ -240,104 +234,7 @@ describe('useLoad3dViewer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('state watchers', () => {
|
||||
it('should update background color when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundColor.value = '#ff0000'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setBackgroundColor).toHaveBeenCalledWith('#ff0000')
|
||||
})
|
||||
|
||||
it('should update grid visibility when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.showGrid.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleGrid).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should update camera type when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.cameraType.value = 'orthographic'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
})
|
||||
|
||||
it('should update FOV when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.fov.value = 90
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setFOV).toHaveBeenCalledWith(90)
|
||||
})
|
||||
|
||||
it('should update light intensity when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.lightIntensity.value = 2
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should update background image when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.backgroundImage.value = 'new-bg.jpg'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setBackgroundImage).toHaveBeenCalledWith('new-bg.jpg')
|
||||
expect(viewer.hasBackgroundImage.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should update up direction when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.upDirection.value = '+y'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
})
|
||||
|
||||
it('should update material mode when state changes', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.materialMode.value = 'wireframe'
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle watcher errors gracefully', async () => {
|
||||
vi.mocked(mockLoad3d.setBackgroundColor!).mockImplementationOnce(
|
||||
function () {
|
||||
@@ -749,4 +646,118 @@ describe('useLoad3dViewer', () => {
|
||||
expect(newViewer.backgroundColor.value).toBe('#0000ff')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gizmo controls', () => {
|
||||
it('should initialize gizmo state from node model config', async () => {
|
||||
;(mockNode.properties!['Model Config'] as Record<string, unknown>).gizmo =
|
||||
{
|
||||
enabled: true,
|
||||
mode: 'rotate'
|
||||
}
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(viewer.gizmoEnabled.value).toBe(true)
|
||||
expect(viewer.gizmoMode.value).toBe('rotate')
|
||||
})
|
||||
|
||||
it('should default gizmo to disabled translate when no config', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
expect(viewer.gizmoEnabled.value).toBe(false)
|
||||
expect(viewer.gizmoMode.value).toBe('translate')
|
||||
})
|
||||
|
||||
it('should persist gizmo state in applyChanges', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.gizmoEnabled.value = true
|
||||
viewer.gizmoMode.value = 'rotate'
|
||||
|
||||
await viewer.applyChanges()
|
||||
|
||||
const modelConfig = mockNode.properties!['Model Config'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const gizmo = modelConfig.gizmo as Record<string, unknown>
|
||||
expect(gizmo.enabled).toBe(true)
|
||||
expect(gizmo.mode).toBe('rotate')
|
||||
})
|
||||
|
||||
it('should save gizmo transform from load3d in applyChanges', async () => {
|
||||
vi.mocked(mockLoad3d.getGizmoTransform!).mockReturnValue({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
})
|
||||
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
await viewer.applyChanges()
|
||||
|
||||
const modelConfig = mockNode.properties!['Model Config'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const gizmo = modelConfig.gizmo as {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
}
|
||||
expect(gizmo.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(gizmo.rotation).toEqual({ x: 0.1, y: 0.2, z: 0.3 })
|
||||
expect(gizmo.scale).toEqual({ x: 2, y: 2, z: 2 })
|
||||
})
|
||||
|
||||
it('should restore gizmo state in restoreInitialState', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
|
||||
viewer.gizmoEnabled.value = true
|
||||
viewer.gizmoMode.value = 'rotate'
|
||||
|
||||
viewer.restoreInitialState()
|
||||
|
||||
const modelConfig = mockNode.properties!['Model Config'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const gizmo = modelConfig.gizmo as Record<string, unknown>
|
||||
expect(gizmo.enabled).toBe(false)
|
||||
expect(gizmo.mode).toBe('translate')
|
||||
})
|
||||
|
||||
it('should restore gizmo state from standalone config cache', async () => {
|
||||
const viewer = useLoad3dViewer()
|
||||
const containerRef = document.createElement('div')
|
||||
const model1 = 'gizmo_model1.glb'
|
||||
|
||||
await viewer.initializeStandaloneViewer(containerRef, model1)
|
||||
viewer.gizmoEnabled.value = true
|
||||
viewer.gizmoMode.value = 'rotate'
|
||||
await nextTick()
|
||||
|
||||
viewer.cleanup()
|
||||
|
||||
const restoredViewer = useLoad3dViewer()
|
||||
await restoredViewer.initializeStandaloneViewer(containerRef, model1)
|
||||
expect(restoredViewer.gizmoEnabled.value).toBe(true)
|
||||
expect(restoredViewer.gizmoMode.value).toBe('rotate')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
CameraType,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
@@ -32,6 +33,8 @@ interface Load3dViewerState {
|
||||
backgroundRenderMode: BackgroundRenderModeType
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
gizmoEnabled: boolean
|
||||
gizmoMode: GizmoMode
|
||||
}
|
||||
|
||||
const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
|
||||
@@ -44,7 +47,9 @@ const DEFAULT_STANDALONE_CONFIG: Load3dViewerState = {
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
gizmoEnabled: false,
|
||||
gizmoMode: 'translate'
|
||||
}
|
||||
|
||||
const standaloneConfigCache = new QuickLRU<string, Load3dViewerState>({
|
||||
@@ -69,6 +74,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
const backgroundRenderMode = ref<BackgroundRenderModeType>('tiled')
|
||||
const upDirection = ref<UpDirection>('original')
|
||||
const materialMode = ref<MaterialMode>('original')
|
||||
const gizmoEnabled = ref(false)
|
||||
const gizmoMode = ref<GizmoMode>('translate')
|
||||
const needApplyChanges = ref(true)
|
||||
const isPreview = ref(false)
|
||||
const isStandaloneMode = ref(false)
|
||||
@@ -98,7 +105,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
upDirection: 'original',
|
||||
materialMode: 'original'
|
||||
materialMode: 'original',
|
||||
gizmoEnabled: false,
|
||||
gizmoMode: 'translate'
|
||||
})
|
||||
|
||||
watch(backgroundColor, (newColor) => {
|
||||
@@ -273,6 +282,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(gizmoEnabled, (newValue) => {
|
||||
if (load3d) {
|
||||
load3d.setGizmoEnabled(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
watch(gizmoMode, (newValue) => {
|
||||
if (load3d) {
|
||||
load3d.setGizmoMode(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Initializes the viewer in node mode using a source Load3d instance.
|
||||
*
|
||||
@@ -367,6 +388,10 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
modelConfig.upDirection || source.modelManager.currentUpDirection
|
||||
materialMode.value =
|
||||
modelConfig.materialMode || source.modelManager.materialMode
|
||||
if (modelConfig.gizmo) {
|
||||
gizmoEnabled.value = modelConfig.gizmo.enabled
|
||||
gizmoMode.value = modelConfig.gizmo.mode
|
||||
}
|
||||
}
|
||||
|
||||
isSplatModel.value = source.isSplatModel()
|
||||
@@ -382,7 +407,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundImage: backgroundImage.value,
|
||||
backgroundRenderMode: backgroundRenderMode.value,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
materialMode: materialMode.value,
|
||||
gizmoEnabled: gizmoEnabled.value,
|
||||
gizmoMode: gizmoMode.value
|
||||
}
|
||||
|
||||
setupAnimationEvents()
|
||||
@@ -475,7 +502,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundImage: backgroundImage.value,
|
||||
backgroundRenderMode: backgroundRenderMode.value,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
materialMode: materialMode.value,
|
||||
gizmoEnabled: gizmoEnabled.value,
|
||||
gizmoMode: gizmoMode.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -497,6 +526,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundRenderMode.value = config.backgroundRenderMode
|
||||
upDirection.value = config.upDirection
|
||||
materialMode.value = config.materialMode
|
||||
gizmoEnabled.value = config.gizmoEnabled
|
||||
gizmoMode.value = config.gizmoMode
|
||||
if (cached?.cameraState && load3d) {
|
||||
load3d.setCameraState(cached.cameraState)
|
||||
}
|
||||
@@ -572,7 +603,14 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
|
||||
nodeValue.properties['Model Config'] = {
|
||||
upDirection: initialState.value.upDirection,
|
||||
materialMode: initialState.value.materialMode
|
||||
materialMode: initialState.value.materialMode,
|
||||
gizmo: {
|
||||
enabled: initialState.value.gizmoEnabled,
|
||||
mode: initialState.value.gizmoMode,
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
const currentCameraConfig = nodeValue.properties['Camera Config'] as
|
||||
@@ -614,9 +652,18 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
intensity: lightIntensity.value
|
||||
}
|
||||
|
||||
const gizmoTransform = load3d.getGizmoTransform()
|
||||
nodeValue.properties['Model Config'] = {
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value
|
||||
materialMode: materialMode.value,
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: gizmoEnabled.value,
|
||||
mode: gizmoMode.value,
|
||||
position: gizmoTransform.position,
|
||||
rotation: gizmoTransform.rotation,
|
||||
scale: gizmoTransform.scale
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,6 +804,8 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
backgroundRenderMode,
|
||||
upDirection,
|
||||
materialMode,
|
||||
gizmoEnabled,
|
||||
gizmoMode,
|
||||
needApplyChanges,
|
||||
isPreview,
|
||||
isStandaloneMode,
|
||||
@@ -784,6 +833,9 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
handleBackgroundImageUpdate,
|
||||
handleModelDrop,
|
||||
handleSeek,
|
||||
resetGizmoTransform: () => {
|
||||
load3d?.resetGizmoTransform()
|
||||
},
|
||||
cleanup,
|
||||
|
||||
hasSkeleton: false,
|
||||
|
||||
@@ -64,6 +64,7 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
).map((w) => `${w.value}`)
|
||||
)
|
||||
if (app.configuringGraph || !this.graph) return
|
||||
if (useWidgetValueStore().isHydrating(this.id)) return
|
||||
if (values.includes(`${comboWidget.value}`)) return
|
||||
comboWidget.value = values[0] ?? ''
|
||||
comboWidget.callback?.(comboWidget.value)
|
||||
@@ -92,12 +93,17 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
store.getOrCreateWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
)
|
||||
if (state) state.value = v
|
||||
widgetName,
|
||||
v
|
||||
).value = v
|
||||
|
||||
if (store.isHydrating(node.id)) return
|
||||
|
||||
updateCombo()
|
||||
if (!node.widgets) return
|
||||
const lastWidget = node.widgets.at(-1)
|
||||
@@ -126,6 +132,13 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
y: 0
|
||||
})
|
||||
addOption(this)
|
||||
|
||||
this.onConfigure = useChainCallback(
|
||||
this.onConfigure,
|
||||
function (this: LGraphNode) {
|
||||
useWidgetValueStore().onHydrationComplete(this.id, updateCombo)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function onCustomIntCreated(this: LGraphNode) {
|
||||
|
||||
@@ -190,28 +190,40 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
setupForModel(size: THREE.Vector3): void {
|
||||
setupForModel(
|
||||
size: THREE.Vector3,
|
||||
center: THREE.Vector3 = new THREE.Vector3(0, size.y / 2, 0)
|
||||
): void {
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const distance = Math.max(size.x, size.z) * 2
|
||||
const height = size.y * 2
|
||||
const height = center.y + maxDim
|
||||
|
||||
this.perspectiveCamera.position.set(distance, height, distance)
|
||||
this.orthographicCamera.position.set(distance, height, distance)
|
||||
this.perspectiveCamera.position.set(
|
||||
center.x + distance,
|
||||
height,
|
||||
center.z + distance
|
||||
)
|
||||
this.orthographicCamera.position.set(
|
||||
center.x + distance,
|
||||
height,
|
||||
center.z + distance
|
||||
)
|
||||
|
||||
if (this.activeCamera === this.perspectiveCamera) {
|
||||
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
|
||||
this.perspectiveCamera.lookAt(center)
|
||||
this.perspectiveCamera.updateProjectionMatrix()
|
||||
} else {
|
||||
const frustumSize = Math.max(size.x, size.y, size.z) * 2
|
||||
const frustumSize = maxDim * 2
|
||||
const aspect = this.perspectiveCamera.aspect
|
||||
this.orthographicCamera.left = (-frustumSize * aspect) / 2
|
||||
this.orthographicCamera.right = (frustumSize * aspect) / 2
|
||||
this.orthographicCamera.top = frustumSize / 2
|
||||
this.orthographicCamera.bottom = -frustumSize / 2
|
||||
this.orthographicCamera.lookAt(0, size.y / 2, 0)
|
||||
this.orthographicCamera.lookAt(center)
|
||||
this.orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.controls?.target.set(0, size.y / 2, 0)
|
||||
this.controls?.target.copy(center)
|
||||
this.controls?.update()
|
||||
}
|
||||
|
||||
|
||||
368
src/extensions/core/load3d/GizmoManager.test.ts
Normal file
368
src/extensions/core/load3d/GizmoManager.test.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
|
||||
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
|
||||
vi.hoisted(() => ({
|
||||
mockSetMode: vi.fn(),
|
||||
mockAttach: vi.fn(),
|
||||
mockDetach: vi.fn(),
|
||||
mockGetHelper: vi.fn(),
|
||||
mockDispose: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/controls/TransformControls', () => {
|
||||
class TransformControls {
|
||||
enabled = true
|
||||
camera: THREE.Camera
|
||||
private listeners = new Map<string, ((e: unknown) => void)[]>()
|
||||
|
||||
constructor(camera: THREE.Camera) {
|
||||
this.camera = camera
|
||||
}
|
||||
|
||||
addEventListener(event: string, cb: (e: unknown) => void) {
|
||||
if (!this.listeners.has(event)) this.listeners.set(event, [])
|
||||
this.listeners.get(event)!.push(cb)
|
||||
}
|
||||
|
||||
setMode = mockSetMode
|
||||
attach = mockAttach
|
||||
detach = mockDetach
|
||||
getHelper = mockGetHelper
|
||||
dispose = mockDispose
|
||||
|
||||
emit(event: string, data: unknown) {
|
||||
for (const cb of this.listeners.get(event) ?? []) cb(data)
|
||||
}
|
||||
}
|
||||
return { TransformControls }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
|
||||
class OrbitControls {
|
||||
enabled = true
|
||||
}
|
||||
return { OrbitControls }
|
||||
})
|
||||
|
||||
function makeMockOrbitControls() {
|
||||
return { enabled: true } as unknown as InstanceType<
|
||||
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
|
||||
>
|
||||
}
|
||||
|
||||
describe('GizmoManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let orbitControls: ReturnType<typeof makeMockOrbitControls>
|
||||
let manager: GizmoManager
|
||||
let onTransformChange: () => void
|
||||
let mockHelper: THREE.Object3D
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
scene = new THREE.Scene()
|
||||
renderer = {
|
||||
domElement: document.createElement('canvas')
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
orbitControls = makeMockOrbitControls()
|
||||
onTransformChange = vi.fn()
|
||||
|
||||
mockHelper = new THREE.Object3D()
|
||||
mockHelper.name = ''
|
||||
mockHelper.renderOrder = 0
|
||||
mockGetHelper.mockReturnValue(mockHelper)
|
||||
|
||||
manager = new GizmoManager(
|
||||
scene,
|
||||
renderer,
|
||||
orbitControls,
|
||||
() => camera,
|
||||
onTransformChange
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('adds helper to scene with correct name and render order', () => {
|
||||
manager.init()
|
||||
|
||||
expect(mockGetHelper).toHaveBeenCalled()
|
||||
expect(mockHelper.name).toBe('GizmoTransformControls')
|
||||
expect(mockHelper.renderOrder).toBe(999)
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupForModel', () => {
|
||||
it('attaches to model and stores initial transform when enabled', () => {
|
||||
manager.init()
|
||||
manager.setEnabled(true)
|
||||
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(mockAttach).toHaveBeenCalledWith(model)
|
||||
expect(mockSetMode).toHaveBeenCalledWith('translate')
|
||||
})
|
||||
|
||||
it('does not attach when disabled', () => {
|
||||
manager.init()
|
||||
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(mockAttach).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing before init', () => {
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(mockDetach).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('attaches to target when enabled with a target', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
vi.mocked(mockAttach).mockClear()
|
||||
manager.setEnabled(true)
|
||||
|
||||
expect(mockAttach).toHaveBeenCalledWith(model)
|
||||
expect(manager.isEnabled()).toBe(true)
|
||||
})
|
||||
|
||||
it('detaches when disabled', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
manager.setEnabled(true)
|
||||
|
||||
vi.mocked(mockDetach).mockClear()
|
||||
manager.setEnabled(false)
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(manager.isEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
it('does nothing before init', () => {
|
||||
manager.setEnabled(true)
|
||||
expect(mockAttach).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detach', () => {
|
||||
it('detaches and clears target', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
manager.setEnabled(true)
|
||||
|
||||
vi.mocked(mockDetach).mockClear()
|
||||
manager.detach()
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(manager.isEnabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setMode / getMode', () => {
|
||||
it('defaults to translate', () => {
|
||||
expect(manager.getMode()).toBe('translate')
|
||||
})
|
||||
|
||||
it('switches to rotate', () => {
|
||||
manager.init()
|
||||
manager.setMode('rotate')
|
||||
|
||||
expect(manager.getMode()).toBe('rotate')
|
||||
expect(mockSetMode).toHaveBeenCalledWith('rotate')
|
||||
})
|
||||
|
||||
it('stores mode before init', () => {
|
||||
manager.setMode('rotate')
|
||||
expect(manager.getMode()).toBe('rotate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('restores initial position, rotation, and scale', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(2, 2, 2)
|
||||
|
||||
manager.setupForModel(model)
|
||||
|
||||
model.position.set(10, 20, 30)
|
||||
model.rotation.set(1, 2, 3)
|
||||
model.scale.set(5, 5, 5)
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(model.position.x).toBeCloseTo(1)
|
||||
expect(model.position.y).toBeCloseTo(2)
|
||||
expect(model.position.z).toBeCloseTo(3)
|
||||
expect(model.rotation.x).toBeCloseTo(0.1)
|
||||
expect(model.rotation.y).toBeCloseTo(0.2)
|
||||
expect(model.rotation.z).toBeCloseTo(0.3)
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(model.scale.y).toBeCloseTo(2)
|
||||
expect(model.scale.z).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() => manager.reset()).not.toThrow()
|
||||
})
|
||||
|
||||
it('invokes onTransformChange after resetting', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
manager.setupForModel(model)
|
||||
|
||||
expect(onTransformChange).not.toHaveBeenCalled()
|
||||
|
||||
manager.reset()
|
||||
|
||||
expect(onTransformChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyTransform', () => {
|
||||
it('sets position and rotation on target', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyTransform({ x: 5, y: 6, z: 7 }, { x: 0.5, y: 0.6, z: 0.7 })
|
||||
|
||||
expect(model.position.x).toBeCloseTo(5)
|
||||
expect(model.position.y).toBeCloseTo(6)
|
||||
expect(model.position.z).toBeCloseTo(7)
|
||||
expect(model.rotation.x).toBeCloseTo(0.5)
|
||||
expect(model.rotation.y).toBeCloseTo(0.6)
|
||||
expect(model.rotation.z).toBeCloseTo(0.7)
|
||||
})
|
||||
|
||||
it('applies scale when provided', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
|
||||
manager.applyTransform(
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{ x: 2, y: 3, z: 4 }
|
||||
)
|
||||
|
||||
expect(model.scale.x).toBeCloseTo(2)
|
||||
expect(model.scale.y).toBeCloseTo(3)
|
||||
expect(model.scale.z).toBeCloseTo(4)
|
||||
})
|
||||
|
||||
it('does nothing without a target', () => {
|
||||
manager.init()
|
||||
expect(() =>
|
||||
manager.applyTransform({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0 })
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransform', () => {
|
||||
it('returns current target transform', () => {
|
||||
manager.init()
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(1, 2, 3)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(4, 5, 6)
|
||||
manager.setupForModel(model)
|
||||
|
||||
const transform = manager.getTransform()
|
||||
|
||||
expect(transform.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(transform.rotation.x).toBeCloseTo(0.1)
|
||||
expect(transform.rotation.y).toBeCloseTo(0.2)
|
||||
expect(transform.rotation.z).toBeCloseTo(0.3)
|
||||
expect(transform.scale).toEqual({ x: 4, y: 5, z: 6 })
|
||||
})
|
||||
|
||||
it('returns zero/identity when no target', () => {
|
||||
const transform = manager.getTransform()
|
||||
|
||||
expect(transform.position).toEqual({ x: 0, y: 0, z: 0 })
|
||||
expect(transform.rotation).toEqual({ x: 0, y: 0, z: 0 })
|
||||
expect(transform.scale).toEqual({ x: 1, y: 1, z: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFromScene / ensureHelperInScene', () => {
|
||||
it('removes helper from scene', () => {
|
||||
manager.init()
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
|
||||
manager.removeFromScene()
|
||||
|
||||
expect(scene.children).not.toContain(mockHelper)
|
||||
})
|
||||
|
||||
it('restores helper to scene', () => {
|
||||
manager.init()
|
||||
manager.removeFromScene()
|
||||
|
||||
manager.ensureHelperInScene()
|
||||
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('removes helper, detaches, and disposes controls', () => {
|
||||
manager.init()
|
||||
scene.add(mockHelper)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
expect(mockDetach).toHaveBeenCalled()
|
||||
expect(mockDispose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is safe to call before init', () => {
|
||||
expect(() => manager.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureHelperInScene', () => {
|
||||
it('re-adds helper if it was removed from its parent', () => {
|
||||
manager.init()
|
||||
// Simulate helper being removed from scene
|
||||
scene.remove(mockHelper)
|
||||
expect(scene.children).not.toContain(mockHelper)
|
||||
|
||||
// setEnabled triggers ensureHelperInScene internally
|
||||
const model = new THREE.Object3D()
|
||||
manager.setupForModel(model)
|
||||
manager.setEnabled(true)
|
||||
|
||||
expect(scene.children).toContain(mockHelper)
|
||||
})
|
||||
})
|
||||
})
|
||||
229
src/extensions/core/load3d/GizmoManager.ts
Normal file
229
src/extensions/core/load3d/GizmoManager.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import * as THREE from 'three'
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
|
||||
import type { GizmoMode } from './interfaces'
|
||||
|
||||
export class GizmoManager {
|
||||
private transformControls: TransformControls | null = null
|
||||
private targetObject: THREE.Object3D | null = null
|
||||
private initialPosition: THREE.Vector3 = new THREE.Vector3()
|
||||
private initialRotation: THREE.Euler = new THREE.Euler()
|
||||
private initialScale: THREE.Vector3 = new THREE.Vector3(1, 1, 1)
|
||||
private enabled: boolean = false
|
||||
private activeCamera: THREE.Camera
|
||||
private mode: GizmoMode = 'translate'
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
orbitControls: OrbitControls,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
onTransformChange?: () => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.orbitControls = orbitControls
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.onTransformChange = onTransformChange
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.transformControls = new TransformControls(
|
||||
this.activeCamera,
|
||||
this.renderer.domElement
|
||||
)
|
||||
|
||||
this.transformControls.addEventListener('dragging-changed', (event) => {
|
||||
this.orbitControls.enabled = !event.value
|
||||
if (!event.value && this.onTransformChange) {
|
||||
this.onTransformChange()
|
||||
}
|
||||
})
|
||||
|
||||
const helper = this.transformControls.getHelper()
|
||||
helper.name = 'GizmoTransformControls'
|
||||
helper.renderOrder = 999
|
||||
this.scene.add(helper)
|
||||
}
|
||||
|
||||
setupForModel(model: THREE.Object3D): void {
|
||||
if (!this.transformControls) return
|
||||
|
||||
this.ensureHelperInScene()
|
||||
|
||||
this.transformControls.detach()
|
||||
this.transformControls.enabled = false
|
||||
|
||||
this.targetObject = model
|
||||
this.initialPosition.copy(model.position)
|
||||
this.initialRotation.copy(model.rotation)
|
||||
this.initialScale.copy(model.scale)
|
||||
|
||||
if (this.enabled) {
|
||||
this.transformControls.attach(model)
|
||||
this.transformControls.setMode(this.mode)
|
||||
this.transformControls.enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.enabled = false
|
||||
if (this.transformControls) {
|
||||
this.transformControls.detach()
|
||||
this.transformControls.enabled = false
|
||||
}
|
||||
this.targetObject = null
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled
|
||||
|
||||
if (!this.transformControls) return
|
||||
|
||||
this.ensureHelperInScene()
|
||||
|
||||
if (enabled && this.targetObject) {
|
||||
this.transformControls.attach(this.targetObject)
|
||||
this.transformControls.setMode(this.mode)
|
||||
this.transformControls.enabled = true
|
||||
} else {
|
||||
this.transformControls.detach()
|
||||
this.transformControls.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
ensureHelperInScene(): void {
|
||||
if (!this.transformControls) return
|
||||
const helper = this.transformControls.getHelper()
|
||||
if (!helper.parent) {
|
||||
this.scene.add(helper)
|
||||
}
|
||||
}
|
||||
|
||||
removeFromScene(): void {
|
||||
if (!this.transformControls) return
|
||||
const helper = this.transformControls.getHelper()
|
||||
if (helper.parent) {
|
||||
helper.parent.remove(helper)
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.enabled
|
||||
}
|
||||
|
||||
updateCamera(camera: THREE.Camera): void {
|
||||
this.activeCamera = camera
|
||||
if (this.transformControls) {
|
||||
this.transformControls.camera = camera
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode: GizmoMode): void {
|
||||
this.mode = mode
|
||||
|
||||
if (this.transformControls) {
|
||||
this.transformControls.setMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
getMode(): GizmoMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (!this.targetObject) return
|
||||
|
||||
this.targetObject.position.copy(this.initialPosition)
|
||||
this.targetObject.rotation.copy(this.initialRotation)
|
||||
this.targetObject.scale.copy(this.initialScale)
|
||||
this.onTransformChange?.()
|
||||
}
|
||||
|
||||
applyTransform(
|
||||
position: { x: number; y: number; z: number },
|
||||
rotation: { x: number; y: number; z: number },
|
||||
scale?: { x: number; y: number; z: number }
|
||||
): void {
|
||||
if (!this.targetObject) return
|
||||
this.targetObject.position.set(position.x, position.y, position.z)
|
||||
this.targetObject.rotation.set(rotation.x, rotation.y, rotation.z)
|
||||
if (scale) {
|
||||
this.targetObject.scale.set(scale.x, scale.y, scale.z)
|
||||
}
|
||||
}
|
||||
|
||||
getInitialTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
} {
|
||||
return {
|
||||
position: {
|
||||
x: this.initialPosition.x,
|
||||
y: this.initialPosition.y,
|
||||
z: this.initialPosition.z
|
||||
},
|
||||
rotation: {
|
||||
x: this.initialRotation.x,
|
||||
y: this.initialRotation.y,
|
||||
z: this.initialRotation.z
|
||||
},
|
||||
scale: {
|
||||
x: this.initialScale.x,
|
||||
y: this.initialScale.y,
|
||||
z: this.initialScale.z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
} {
|
||||
if (!this.targetObject) {
|
||||
return {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
position: {
|
||||
x: this.targetObject.position.x,
|
||||
y: this.targetObject.position.y,
|
||||
z: this.targetObject.position.z
|
||||
},
|
||||
rotation: {
|
||||
x: this.targetObject.rotation.x,
|
||||
y: this.targetObject.rotation.y,
|
||||
z: this.targetObject.rotation.z
|
||||
},
|
||||
scale: {
|
||||
x: this.targetObject.scale.x,
|
||||
y: this.targetObject.scale.y,
|
||||
z: this.targetObject.scale.z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.transformControls) {
|
||||
const helper = this.transformControls.getHelper()
|
||||
this.scene.remove(helper)
|
||||
this.transformControls.detach()
|
||||
this.transformControls.dispose()
|
||||
this.transformControls = null
|
||||
}
|
||||
|
||||
this.targetObject = null
|
||||
}
|
||||
}
|
||||
164
src/extensions/core/load3d/Load3DConfiguration.test.ts
Normal file
164
src/extensions/core/load3d/Load3DConfiguration.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import type {
|
||||
GizmoConfig,
|
||||
ModelConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (p: string) => p,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
fetchApi: vi.fn(),
|
||||
getSystemStats: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { rootGraph: { extra: {} } }
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3d', () => ({ default: class {} }))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
getResourceURL: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
type WithPrivate = { loadModelConfig(): ModelConfig }
|
||||
|
||||
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
|
||||
const load3d = {} as Load3d
|
||||
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
|
||||
}
|
||||
|
||||
const defaultGizmo: GizmoConfig = {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
|
||||
describe('Load3DConfiguration.loadModelConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns full defaults including gizmo when no properties are provided', () => {
|
||||
const result = createConfig().loadModelConfig()
|
||||
|
||||
expect(result).toEqual({
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: defaultGizmo
|
||||
})
|
||||
})
|
||||
|
||||
it('returns full defaults when properties do not contain Model Config', () => {
|
||||
const result = createConfig({ 'Other Key': 'x' }).loadModelConfig()
|
||||
|
||||
expect(result.gizmo).toEqual(defaultGizmo)
|
||||
})
|
||||
|
||||
it('adds default gizmo when Model Config exists but has no gizmo field', () => {
|
||||
const stored: ModelConfig = {
|
||||
upDirection: '+y',
|
||||
materialMode: 'wireframe',
|
||||
showSkeleton: true
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
const result = createConfig(properties).loadModelConfig()
|
||||
|
||||
expect(result.upDirection).toBe('+y')
|
||||
expect(result.materialMode).toBe('wireframe')
|
||||
expect(result.showSkeleton).toBe(true)
|
||||
expect(result.gizmo).toEqual(defaultGizmo)
|
||||
})
|
||||
|
||||
it('mutates the original Model Config property to persist gizmo defaults', () => {
|
||||
const stored: ModelConfig = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
createConfig(properties).loadModelConfig()
|
||||
|
||||
expect((properties['Model Config'] as ModelConfig).gizmo).toEqual(
|
||||
defaultGizmo
|
||||
)
|
||||
})
|
||||
|
||||
it('backfills scale on legacy gizmo config missing the scale field', () => {
|
||||
const legacyGizmo = {
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 }
|
||||
} as unknown as GizmoConfig
|
||||
const stored: ModelConfig = {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: legacyGizmo
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
const result = createConfig(properties).loadModelConfig()
|
||||
|
||||
expect(result.gizmo).toEqual({
|
||||
enabled: true,
|
||||
mode: 'rotate',
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves a fully populated gizmo config unchanged', () => {
|
||||
const fullGizmo: GizmoConfig = {
|
||||
enabled: true,
|
||||
mode: 'scale',
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 1, y: 2, z: 3 },
|
||||
scale: { x: 2, y: 2, z: 2 }
|
||||
}
|
||||
const stored: ModelConfig = {
|
||||
upDirection: '-z',
|
||||
materialMode: 'normal',
|
||||
showSkeleton: false,
|
||||
gizmo: fullGizmo
|
||||
}
|
||||
const properties = { 'Model Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
const result = createConfig(properties).loadModelConfig()
|
||||
|
||||
expect(result.gizmo).toEqual(fullGizmo)
|
||||
})
|
||||
})
|
||||
@@ -167,13 +167,32 @@ class Load3DConfiguration {
|
||||
|
||||
private loadModelConfig(): ModelConfig {
|
||||
if (this.properties && 'Model Config' in this.properties) {
|
||||
return this.properties['Model Config'] as ModelConfig
|
||||
const config = this.properties['Model Config'] as ModelConfig
|
||||
if (!config.gizmo) {
|
||||
config.gizmo = {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
} else if (!config.gizmo.scale) {
|
||||
config.gizmo.scale = { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
269
src/extensions/core/load3d/Load3d.test.ts
Normal file
269
src/extensions/core/load3d/Load3d.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
type GizmoStub = {
|
||||
setEnabled: ReturnType<typeof vi.fn>
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
applyTransform: ReturnType<typeof vi.fn>
|
||||
getTransform: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
updateCamera: ReturnType<typeof vi.fn>
|
||||
detach: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
removeFromScene: ReturnType<typeof vi.fn>
|
||||
ensureHelperInScene: ReturnType<typeof vi.fn>
|
||||
isEnabled: ReturnType<typeof vi.fn>
|
||||
getMode: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type ModelManagerStub = {
|
||||
fitToViewer: ReturnType<typeof vi.fn>
|
||||
clearModel: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type CameraManagerStub = {
|
||||
toggleCamera: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
reset: ReturnType<typeof vi.fn>
|
||||
activeCamera: THREE.Camera
|
||||
}
|
||||
|
||||
type SceneManagerStub = {
|
||||
captureScene: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
type Load3dPrivate = {
|
||||
setGizmo(model: THREE.Object3D): void
|
||||
setupCamera(size: THREE.Vector3, center: THREE.Vector3): void
|
||||
}
|
||||
|
||||
function makeGizmoStub(): GizmoStub {
|
||||
return {
|
||||
setEnabled: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
applyTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => ({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
})),
|
||||
setupForModel: vi.fn(),
|
||||
updateCamera: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
removeFromScene: vi.fn(),
|
||||
ensureHelperInScene: vi.fn(),
|
||||
isEnabled: vi.fn(() => false),
|
||||
getMode: vi.fn(() => 'translate')
|
||||
}
|
||||
}
|
||||
|
||||
function makeInstance() {
|
||||
const gizmo = makeGizmoStub()
|
||||
const modelManager: ModelManagerStub = {
|
||||
fitToViewer: vi.fn(),
|
||||
clearModel: vi.fn()
|
||||
}
|
||||
const cameraManager: CameraManagerStub = {
|
||||
toggleCamera: vi.fn(),
|
||||
setupForModel: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera()
|
||||
}
|
||||
const sceneManager: SceneManagerStub = {
|
||||
captureScene: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
const controlsManager = { updateCamera: vi.fn() }
|
||||
const viewHelperManager = { recreateViewHelper: vi.fn() }
|
||||
const animationManager = { dispose: vi.fn() }
|
||||
|
||||
// Load3d's constructor instantiates THREE.WebGLRenderer, ResizeObserver
|
||||
// and ViewHelper, none of which are available in happy-dom. Skip it and
|
||||
// inject stubs directly onto the prototype instance so delegation methods
|
||||
// can be exercised in isolation.
|
||||
const load3d = Object.create(Load3d.prototype) as Load3d
|
||||
Object.assign(load3d, {
|
||||
gizmoManager: gizmo,
|
||||
modelManager,
|
||||
cameraManager,
|
||||
sceneManager,
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
})
|
||||
|
||||
return {
|
||||
load3d,
|
||||
gizmo,
|
||||
modelManager,
|
||||
cameraManager,
|
||||
sceneManager,
|
||||
controlsManager,
|
||||
viewHelperManager,
|
||||
animationManager,
|
||||
forceRender: load3d.forceRender as ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
describe('Load3d', () => {
|
||||
let ctx: ReturnType<typeof makeInstance>
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = makeInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('gizmo delegation', () => {
|
||||
it('getGizmoManager returns the underlying manager', () => {
|
||||
expect(ctx.load3d.getGizmoManager()).toBe(ctx.gizmo)
|
||||
})
|
||||
|
||||
it('setGizmoEnabled delegates to gizmoManager.setEnabled and forces a render', () => {
|
||||
ctx.load3d.setGizmoEnabled(true)
|
||||
|
||||
expect(ctx.gizmo.setEnabled).toHaveBeenCalledWith(true)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['translate', 'rotate', 'scale'] as const)(
|
||||
'setGizmoMode delegates "%s" and forces a render',
|
||||
(mode: GizmoMode) => {
|
||||
ctx.load3d.setGizmoMode(mode)
|
||||
|
||||
expect(ctx.gizmo.setMode).toHaveBeenCalledWith(mode)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
|
||||
it('resetGizmoTransform delegates to gizmoManager.reset and forces a render', () => {
|
||||
ctx.load3d.resetGizmoTransform()
|
||||
|
||||
expect(ctx.gizmo.reset).toHaveBeenCalledOnce()
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applyGizmoTransform forwards position, rotation and scale', () => {
|
||||
const pos = { x: 1, y: 2, z: 3 }
|
||||
const rot = { x: 0.1, y: 0.2, z: 0.3 }
|
||||
const scale = { x: 2, y: 2, z: 2 }
|
||||
|
||||
ctx.load3d.applyGizmoTransform(pos, rot, scale)
|
||||
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, scale)
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('applyGizmoTransform forwards undefined scale when not provided', () => {
|
||||
const pos = { x: 0, y: 0, z: 0 }
|
||||
const rot = { x: 0, y: 0, z: 0 }
|
||||
|
||||
ctx.load3d.applyGizmoTransform(pos, rot)
|
||||
|
||||
expect(ctx.gizmo.applyTransform).toHaveBeenCalledWith(pos, rot, undefined)
|
||||
})
|
||||
|
||||
it('getGizmoTransform returns the gizmoManager transform', () => {
|
||||
const transform = {
|
||||
position: { x: 5, y: 6, z: 7 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
ctx.gizmo.getTransform.mockReturnValue(transform)
|
||||
|
||||
expect(ctx.load3d.getGizmoTransform()).toEqual(transform)
|
||||
})
|
||||
|
||||
it('fitToViewer delegates to modelManager and forces a render', () => {
|
||||
ctx.load3d.fitToViewer()
|
||||
|
||||
expect(ctx.modelManager.fitToViewer).toHaveBeenCalledOnce()
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('lifecycle interactions', () => {
|
||||
it('clearModel detaches the gizmo before clearing the model', () => {
|
||||
const order: string[] = []
|
||||
ctx.animationManager.dispose.mockImplementation(() =>
|
||||
order.push('animation')
|
||||
)
|
||||
ctx.gizmo.detach.mockImplementation(() => order.push('detach'))
|
||||
ctx.modelManager.clearModel.mockImplementation(() => order.push('clear'))
|
||||
|
||||
ctx.load3d.clearModel()
|
||||
|
||||
expect(order).toEqual(['animation', 'detach', 'clear'])
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggleCamera updates both controls and gizmo with the active camera', () => {
|
||||
ctx.load3d.toggleCamera('orthographic')
|
||||
|
||||
expect(ctx.cameraManager.toggleCamera).toHaveBeenCalledWith(
|
||||
'orthographic'
|
||||
)
|
||||
expect(ctx.controlsManager.updateCamera).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.gizmo.updateCamera).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.viewHelperManager.recreateViewHelper).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('setGizmo (private) forwards the model to gizmoManager.setupForModel', () => {
|
||||
const model = new THREE.Object3D()
|
||||
|
||||
;(ctx.load3d as unknown as Load3dPrivate).setGizmo(model)
|
||||
|
||||
expect(ctx.gizmo.setupForModel).toHaveBeenCalledWith(model)
|
||||
})
|
||||
|
||||
it('setupCamera (private) forwards size and center to cameraManager', () => {
|
||||
const size = new THREE.Vector3(1, 2, 3)
|
||||
const center = new THREE.Vector3(4, 5, 6)
|
||||
|
||||
;(ctx.load3d as unknown as Load3dPrivate).setupCamera(size, center)
|
||||
|
||||
expect(ctx.cameraManager.setupForModel).toHaveBeenCalledWith(size, center)
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureScene', () => {
|
||||
it('hides the gizmo helper during capture and restores it after success', async () => {
|
||||
const captureResult = { scene: 'a', mask: 'b', normal: 'c' }
|
||||
ctx.sceneManager.captureScene.mockResolvedValue(captureResult)
|
||||
|
||||
const result = await ctx.load3d.captureScene(100, 200)
|
||||
|
||||
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledBefore(
|
||||
ctx.sceneManager.captureScene
|
||||
)
|
||||
expect(ctx.sceneManager.captureScene).toHaveBeenCalledWith(100, 200)
|
||||
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
|
||||
expect(result).toBe(captureResult)
|
||||
})
|
||||
|
||||
it('restores the gizmo helper even when capture fails', async () => {
|
||||
const err = new Error('capture failed')
|
||||
ctx.sceneManager.captureScene.mockRejectedValue(err)
|
||||
|
||||
await expect(ctx.load3d.captureScene(100, 200)).rejects.toBe(err)
|
||||
|
||||
expect(ctx.gizmo.removeFromScene).toHaveBeenCalledOnce()
|
||||
expect(ctx.gizmo.ensureHelperInScene).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { GizmoManager } from './GizmoManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
@@ -14,13 +15,14 @@ import { RecordingManager } from './RecordingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { SceneModelManager } from './SceneModelManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import {
|
||||
type CameraState,
|
||||
type CaptureResult,
|
||||
type EventCallback,
|
||||
type Load3DOptions,
|
||||
type MaterialMode,
|
||||
type UpDirection
|
||||
import type {
|
||||
CameraState,
|
||||
CaptureResult,
|
||||
EventCallback,
|
||||
GizmoMode,
|
||||
Load3DOptions,
|
||||
MaterialMode,
|
||||
UpDirection
|
||||
} from './interfaces'
|
||||
|
||||
function positionThumbnailCamera(
|
||||
@@ -61,6 +63,7 @@ class Load3d {
|
||||
modelManager: SceneModelManager
|
||||
recordingManager: RecordingManager
|
||||
animationManager: AnimationManager
|
||||
gizmoManager: GizmoManager
|
||||
|
||||
STATUS_MOUSE_ON_NODE: boolean
|
||||
STATUS_MOUSE_ON_SCENE: boolean
|
||||
@@ -146,7 +149,8 @@ class Load3d {
|
||||
this.renderer,
|
||||
this.eventManager,
|
||||
this.getActiveCamera.bind(this),
|
||||
this.setupCamera.bind(this)
|
||||
this.setupCamera.bind(this),
|
||||
this.setGizmo.bind(this)
|
||||
)
|
||||
|
||||
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
|
||||
@@ -158,12 +162,29 @@ class Load3d {
|
||||
)
|
||||
|
||||
this.animationManager = new AnimationManager(this.eventManager)
|
||||
|
||||
this.gizmoManager = new GizmoManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.controlsManager.controls,
|
||||
this.getActiveCamera.bind(this),
|
||||
() => {
|
||||
const transform = this.gizmoManager.getTransform()
|
||||
this.eventManager.emitEvent('gizmoTransformChange', {
|
||||
...transform,
|
||||
enabled: this.gizmoManager.isEnabled(),
|
||||
mode: this.gizmoManager.getMode()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
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()
|
||||
@@ -287,6 +308,10 @@ class Load3d {
|
||||
return this.recordingManager
|
||||
}
|
||||
|
||||
getGizmoManager(): GizmoManager {
|
||||
return this.gizmoManager
|
||||
}
|
||||
|
||||
getTargetSize(): { width: number; height: number } {
|
||||
return {
|
||||
width: this.targetWidth,
|
||||
@@ -388,8 +413,12 @@ class Load3d {
|
||||
return this.controlsManager.controls
|
||||
}
|
||||
|
||||
private setupCamera(size: THREE.Vector3): void {
|
||||
this.cameraManager.setupForModel(size)
|
||||
private setGizmo(model: THREE.Object3D): void {
|
||||
this.gizmoManager.setupForModel(model)
|
||||
}
|
||||
|
||||
private setupCamera(size: THREE.Vector3, center: THREE.Vector3): void {
|
||||
this.cameraManager.setupForModel(size, center)
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
@@ -551,6 +580,7 @@ class Load3d {
|
||||
this.cameraManager.toggleCamera(cameraType)
|
||||
|
||||
this.controlsManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.gizmoManager.updateCamera(this.cameraManager.activeCamera)
|
||||
this.viewHelperManager.recreateViewHelper()
|
||||
|
||||
this.handleResize()
|
||||
@@ -601,6 +631,7 @@ class Load3d {
|
||||
): Promise<void> {
|
||||
this.cameraManager.reset()
|
||||
this.controlsManager.reset()
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.animationManager.dispose()
|
||||
|
||||
@@ -629,6 +660,7 @@ class Load3d {
|
||||
|
||||
clearModel(): void {
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.detach()
|
||||
this.modelManager.clearModel()
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -736,7 +768,11 @@ class Load3d {
|
||||
}
|
||||
|
||||
captureScene(width: number, height: number): Promise<CaptureResult> {
|
||||
return this.sceneManager.captureScene(width, height)
|
||||
this.gizmoManager.removeFromScene()
|
||||
|
||||
return this.sceneManager.captureScene(width, height).finally(() => {
|
||||
this.gizmoManager.ensureHelperInScene()
|
||||
})
|
||||
}
|
||||
|
||||
public async startRecording(): Promise<void> {
|
||||
@@ -853,7 +889,7 @@ class Load3d {
|
||||
this.controlsManager.controls.update()
|
||||
}
|
||||
|
||||
const result = await this.sceneManager.captureScene(width, height)
|
||||
const result = await this.captureScene(width, height)
|
||||
return result.scene
|
||||
} finally {
|
||||
this.sceneManager.gridHelper.visible = savedGridVisible
|
||||
@@ -866,6 +902,43 @@ class Load3d {
|
||||
}
|
||||
}
|
||||
|
||||
public setGizmoEnabled(enabled: boolean): void {
|
||||
this.gizmoManager.setEnabled(enabled)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public setGizmoMode(mode: GizmoMode): void {
|
||||
this.gizmoManager.setMode(mode)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public resetGizmoTransform(): void {
|
||||
this.gizmoManager.reset()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public applyGizmoTransform(
|
||||
position: { x: number; y: number; z: number },
|
||||
rotation: { x: number; y: number; z: number },
|
||||
scale?: { x: number; y: number; z: number }
|
||||
): void {
|
||||
this.gizmoManager.applyTransform(position, rotation, scale)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public getGizmoTransform(): {
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
} {
|
||||
return this.gizmoManager.getTransform()
|
||||
}
|
||||
|
||||
public fitToViewer(): void {
|
||||
this.modelManager.fitToViewer()
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
public remove(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
@@ -899,6 +972,7 @@ class Load3d {
|
||||
this.modelManager.dispose()
|
||||
this.recordingManager.dispose()
|
||||
this.animationManager.dispose()
|
||||
this.gizmoManager.dispose()
|
||||
|
||||
this.renderer.dispose()
|
||||
this.renderer.domElement.remove()
|
||||
|
||||
@@ -9,10 +9,10 @@ import {
|
||||
} from './interfaces'
|
||||
|
||||
export class SceneManager implements SceneManagerInterface {
|
||||
scene: THREE.Scene
|
||||
scene!: THREE.Scene
|
||||
gridHelper: THREE.GridHelper
|
||||
|
||||
backgroundScene: THREE.Scene
|
||||
backgroundScene!: THREE.Scene
|
||||
backgroundCamera: THREE.OrthographicCamera
|
||||
backgroundMesh: THREE.Mesh | null = null
|
||||
backgroundTexture: THREE.Texture | null = null
|
||||
@@ -38,6 +38,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.eventManager = eventManager
|
||||
this.scene = new THREE.Scene()
|
||||
|
||||
this.scene.name = 'MainScene'
|
||||
|
||||
this.getActiveCamera = getActiveCamera
|
||||
|
||||
this.gridHelper = new THREE.GridHelper(20, 20)
|
||||
@@ -45,6 +47,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene.add(this.gridHelper)
|
||||
|
||||
this.backgroundScene = new THREE.Scene()
|
||||
this.backgroundScene.name = 'BackgroundScene'
|
||||
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
|
||||
|
||||
this.initBackgroundScene()
|
||||
@@ -93,6 +96,8 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.scene.background = null
|
||||
}
|
||||
|
||||
this.backgroundScene.clear()
|
||||
|
||||
this.scene.clear()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,16 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private eventManager: EventManagerInterface
|
||||
private activeCamera: THREE.Camera
|
||||
private setupCamera: (size: THREE.Vector3) => void
|
||||
private setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void
|
||||
private setupGizmo: (model: THREE.Object3D) => void
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
setupCamera: (size: THREE.Vector3) => void
|
||||
setupCamera: (size: THREE.Vector3, center: THREE.Vector3) => void,
|
||||
setupGizmo: (model: THREE.Object3D) => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
@@ -52,6 +54,7 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.setupCamera = setupCamera
|
||||
this.textureLoader = new THREE.TextureLoader()
|
||||
this.setupGizmo = setupGizmo
|
||||
|
||||
this.normalMaterial = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
@@ -371,32 +374,31 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
clearModel(): void {
|
||||
const objectsToRemove: THREE.Object3D[] = []
|
||||
|
||||
this.scene.traverse((object) => {
|
||||
for (const object of [...this.scene.children]) {
|
||||
const isEnvironmentObject =
|
||||
object instanceof THREE.GridHelper ||
|
||||
object instanceof THREE.Light ||
|
||||
object instanceof THREE.Camera
|
||||
object instanceof THREE.Camera ||
|
||||
object.name === 'GizmoTransformControls'
|
||||
|
||||
if (!isEnvironmentObject) {
|
||||
objectsToRemove.push(object)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
objectsToRemove.forEach((obj) => {
|
||||
if (obj.parent && obj.parent !== this.scene) {
|
||||
obj.parent.remove(obj)
|
||||
} else {
|
||||
this.scene.remove(obj)
|
||||
}
|
||||
this.scene.remove(obj)
|
||||
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose()
|
||||
if (Array.isArray(obj.material)) {
|
||||
obj.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
obj.material?.dispose()
|
||||
obj.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry?.dispose()
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material?.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.reset()
|
||||
@@ -497,25 +499,10 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
// SplatMesh handles its own rendering, just add to scene
|
||||
this.scene.add(model)
|
||||
// Set a default camera distance for splat models
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5))
|
||||
this.setupCamera(new THREE.Vector3(5, 5, 5), new THREE.Vector3(0, 2.5, 0))
|
||||
return
|
||||
}
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
model.scale.multiplyScalar(scale)
|
||||
|
||||
box.setFromObject(model)
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
model.position.set(-center.x, -box.min.y, -center.z)
|
||||
|
||||
this.scene.add(model)
|
||||
|
||||
if (this.materialMode !== 'original') {
|
||||
@@ -527,7 +514,47 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
this.setupCamera(size)
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
this.setupCamera(size, center)
|
||||
|
||||
this.setupGizmo(model)
|
||||
}
|
||||
|
||||
fitToViewer(): void {
|
||||
if (!this.currentModel || this.containsSplatMesh()) return
|
||||
const model = this.currentModel
|
||||
|
||||
// Reset transform to compute from raw geometry (idempotent)
|
||||
model.scale.set(1, 1, 1)
|
||||
model.position.set(0, 0, 0)
|
||||
model.rotation.set(0, 0, 0)
|
||||
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
if (maxDim === 0) return
|
||||
|
||||
const targetSize = 5
|
||||
const scale = targetSize / maxDim
|
||||
model.scale.set(scale, scale, scale)
|
||||
|
||||
box.setFromObject(model)
|
||||
box.getCenter(center)
|
||||
box.getSize(size)
|
||||
|
||||
model.position.set(-center.x, -box.min.y, -center.z)
|
||||
|
||||
const newBox = new THREE.Box3().setFromObject(model)
|
||||
const newSize = newBox.getSize(new THREE.Vector3())
|
||||
const newCenter = newBox.getCenter(new THREE.Vector3())
|
||||
|
||||
this.setupCamera(newSize, newCenter)
|
||||
this.setupGizmo(model)
|
||||
}
|
||||
|
||||
containsSplatMesh(model?: THREE.Object3D | null): boolean {
|
||||
@@ -548,6 +575,8 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
setUpDirection(direction: UpDirection): void {
|
||||
if (!this.currentModel) return
|
||||
|
||||
const directionChanged = this.currentUpDirection !== direction
|
||||
|
||||
if (!this.originalRotation && this.currentModel.rotation) {
|
||||
this.originalRotation = this.currentModel.rotation.clone()
|
||||
}
|
||||
@@ -581,5 +610,9 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('upDirectionChange', direction)
|
||||
|
||||
if (directionChanged) {
|
||||
this.setupGizmo(this.currentModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,21 @@ export interface SceneConfig {
|
||||
backgroundRenderMode?: BackgroundRenderModeType
|
||||
}
|
||||
|
||||
export type GizmoMode = 'translate' | 'rotate' | 'scale'
|
||||
|
||||
export interface GizmoConfig {
|
||||
enabled: boolean
|
||||
mode: GizmoMode
|
||||
position: { x: number; y: number; z: number }
|
||||
rotation: { x: number; y: number; z: number }
|
||||
scale: { x: number; y: number; z: number }
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
upDirection: UpDirection
|
||||
materialMode: MaterialMode
|
||||
showSkeleton: boolean
|
||||
gizmo?: GizmoConfig
|
||||
}
|
||||
|
||||
export interface CameraConfig {
|
||||
|
||||
@@ -18,7 +18,6 @@ app.registerExtension({
|
||||
suggestionsNumber: null,
|
||||
init(this: SlotDefaultsExtension) {
|
||||
LiteGraph.search_filter_enabled = true
|
||||
LiteGraph.middle_click_slot_add_default_node = true
|
||||
this.suggestionsNumber = app.ui.settings.addSetting({
|
||||
id: 'Comfy.NodeSuggestions.number',
|
||||
category: ['Comfy', 'Node Search Box', 'NodeSuggestions'],
|
||||
|
||||
@@ -103,6 +103,16 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
override serialize() {
|
||||
const o = super.serialize()
|
||||
// PrimitiveNode creates widgets dynamically on connection. When
|
||||
// disconnected, this.widgets is empty so the base serialize() omits
|
||||
// widgets_values. Fall back to the snapshot saved during configure().
|
||||
if (!o.widgets_values && this.widgets_values)
|
||||
o.widgets_values = [...this.widgets_values]
|
||||
return o
|
||||
}
|
||||
|
||||
override onAfterGraphConfigured() {
|
||||
if (this.outputs[0].links?.length && !this.widgets?.length) {
|
||||
this._onFirstConnection()
|
||||
|
||||
@@ -3991,9 +3991,24 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Nodes
|
||||
if (item.clonable === false) continue
|
||||
|
||||
const cloned = item.clone()?.serialize()
|
||||
// Serialize the original node directly instead of clone().serialize().
|
||||
// clone() creates a transient node whose state can diverge from the
|
||||
// original (e.g. SubgraphNode promotionStore keyed by wrong id,
|
||||
// PrimitiveNode losing widgets_values). ID deduplication is already
|
||||
// handled on the deserialize side by _deserializeItems. (#9976)
|
||||
const cloned = LiteGraph.cloneObject(item.serialize())
|
||||
if (!cloned) continue
|
||||
|
||||
// Clear links on the serialized copy (clone() used to do this).
|
||||
if (cloned.inputs) {
|
||||
for (const input of cloned.inputs) input.link = null
|
||||
}
|
||||
if (cloned.outputs) {
|
||||
for (const output of cloned.outputs) {
|
||||
if (output.links) output.links.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
cloned.id = item.id
|
||||
serialisable.nodes.push(cloned)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NodeInputSlot,
|
||||
NodeOutputSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils'
|
||||
@@ -588,6 +589,76 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('configure hydration transaction', () => {
|
||||
test('wraps widget-value restoration in hydration transaction', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const hydrationLog: boolean[] = []
|
||||
|
||||
const testNode = new LGraphNode('TestNode')
|
||||
testNode.serialize_widgets = true
|
||||
testNode.addWidget('number', 'a', 0, null)
|
||||
testNode.addWidget('number', 'b', 0, null)
|
||||
|
||||
// Spy on widget value setters to record hydration state
|
||||
const storage = new Map<string, unknown>()
|
||||
for (const widget of testNode.widgets!) {
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get: () => storage.get(widget.name),
|
||||
set(v) {
|
||||
hydrationLog.push(store.isHydrating(testNode.id))
|
||||
storage.set(widget.name, v)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
testNode.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 42,
|
||||
widgets_values: [10, 20]
|
||||
})
|
||||
)
|
||||
|
||||
// Both widget setters ran while hydration was active
|
||||
expect(hydrationLog.every(Boolean)).toBe(true)
|
||||
// Hydration is complete after configure returns
|
||||
expect(store.isHydrating(42)).toBe(false)
|
||||
})
|
||||
|
||||
test('fires onHydrationComplete callbacks after configure', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const calls: string[] = []
|
||||
|
||||
const testNode = new LGraphNode('TestNode')
|
||||
testNode.serialize_widgets = true
|
||||
testNode.addWidget('number', 'a', 0, null)
|
||||
|
||||
testNode.onConfigure = function () {
|
||||
store.onHydrationComplete(this.id, () => calls.push('done'))
|
||||
}
|
||||
|
||||
testNode.configure(
|
||||
getMockISerialisedNode({ id: 99, widgets_values: [42] })
|
||||
)
|
||||
|
||||
expect(calls).toEqual(['done'])
|
||||
})
|
||||
|
||||
test('commitHydration is safe even if onConfigure throws', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
const testNode = new LGraphNode('TestNode')
|
||||
testNode.onConfigure = () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
testNode.configure(getMockISerialisedNode({ id: 7 }))
|
||||
).toThrow('boom')
|
||||
expect(store.isHydrating(7)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputSlotPos', () => {
|
||||
let inputSlot: INodeInputSlot
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
@@ -897,44 +900,54 @@ export class LGraphNode
|
||||
// SubgraphNode callback.
|
||||
this._internalConfigureAfterSlots?.()
|
||||
|
||||
if (this.widgets) {
|
||||
for (const w of this.widgets) {
|
||||
if (!w) continue
|
||||
// Hydration transaction: suppress derived-state callbacks (e.g.
|
||||
// CustomCombo's updateCombo) until all widget values are restored.
|
||||
// onConfigure handlers may commit early (CustomCombo does); the
|
||||
// final commitHydration is idempotent in that case.
|
||||
const store = getActivePinia() ? useWidgetValueStore() : null
|
||||
store?.beginHydration(this.id)
|
||||
try {
|
||||
if (this.widgets) {
|
||||
for (const w of this.widgets) {
|
||||
if (!w) continue
|
||||
|
||||
const input = this.inputs.find((i) => i.widget?.name === w.name)
|
||||
if (input?.label) w.label = input.label
|
||||
const input = this.inputs.find((i) => i.widget?.name === w.name)
|
||||
if (input?.label) w.label = input.label
|
||||
|
||||
if (
|
||||
w.options?.property &&
|
||||
this.properties[w.options.property] != undefined
|
||||
)
|
||||
w.value = JSON.parse(
|
||||
JSON.stringify(this.properties[w.options.property])
|
||||
if (
|
||||
w.options?.property &&
|
||||
this.properties[w.options.property] != undefined
|
||||
)
|
||||
}
|
||||
w.value = JSON.parse(
|
||||
JSON.stringify(this.properties[w.options.property])
|
||||
)
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
if (i >= info.widgets_values.length) break
|
||||
widget.value = info.widgets_values[i++]
|
||||
if (info.widgets_values) {
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
if (i >= info.widgets_values.length) break
|
||||
widget.value = info.widgets_values[i++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the state of this.resizable.
|
||||
if (this.pinned) this.resizable = false
|
||||
|
||||
if (this.widgets_up) {
|
||||
console.warn(
|
||||
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
|
||||
'This property is unsupported and will be removed. ' +
|
||||
'Use "widgets_start_y" or a custom arrange() override instead.'
|
||||
)
|
||||
}
|
||||
|
||||
this.onConfigure?.(info)
|
||||
} finally {
|
||||
store?.commitHydration(this.id)
|
||||
}
|
||||
|
||||
// Sync the state of this.resizable.
|
||||
if (this.pinned) this.resizable = false
|
||||
|
||||
if (this.widgets_up) {
|
||||
console.warn(
|
||||
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
|
||||
'This property is unsupported and will be removed. ' +
|
||||
'Use "widgets_start_y" or a custom arrange() override instead.'
|
||||
)
|
||||
}
|
||||
|
||||
this.onConfigure?.(info)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
206
src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts
Normal file
206
src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Tests for SubgraphNode serialization state isolation.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. serialize() correctly captures instance-scoped promotion metadata
|
||||
* 2. Direct serialization (without clone()) preserves correct state — the
|
||||
* _serializeItems path uses item.serialize() for all nodes, avoiding the
|
||||
* clone→serialize gap where transient nodes lose external state
|
||||
* 3. Subgraph definition serialization preserves modified widget values
|
||||
*
|
||||
* @see https://github.com/Comfy-Org/ComfyUI_frontend/issues/9976
|
||||
*/
|
||||
import { describe, expect } from 'vitest'
|
||||
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { subgraphTest as test } from './__fixtures__/subgraphFixtures'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
/**
|
||||
* Creates a subgraph with a single interior node that has a widget,
|
||||
* wired through a subgraph input. This creates the promotion binding
|
||||
* that serialize() captures in proxyWidgets.
|
||||
*
|
||||
* Builds on the shared createTestSubgraph + createTestSubgraphNode helpers,
|
||||
* adding only the widget wiring that the base helpers don't support.
|
||||
*/
|
||||
function createSubgraphWithWidgetNode(): {
|
||||
rootGraph: LGraph
|
||||
subgraph: Subgraph
|
||||
interiorNode: LGraphNode
|
||||
subgraphNode: SubgraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph({ name: 'Test Subgraph' })
|
||||
const rootGraph = subgraph.rootGraph
|
||||
|
||||
// Interior node with a widget
|
||||
const interiorNode = new LGraphNode('TestInterior')
|
||||
interiorNode.serialize_widgets = true
|
||||
const nodeInput = interiorNode.addInput('seed', 'INT')
|
||||
nodeInput.widget = { name: 'seed' }
|
||||
interiorNode.addWidget('number', 'seed', 42, () => {})
|
||||
interiorNode.addOutput('out', 'INT')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
// Wire subgraph input → interior node widget input (creates promotion binding)
|
||||
const sgInput = subgraph.addInput('seed', 'INT')
|
||||
sgInput.connect(nodeInput, interiorNode)
|
||||
|
||||
// Shared helper handles SubgraphNode construction (which registers promotions
|
||||
// via _resolveInputWidget under its own id).
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
return { rootGraph, subgraph, interiorNode, subgraphNode }
|
||||
}
|
||||
|
||||
describe('SubgraphNode.serialize() state isolation (#9976)', () => {
|
||||
test('inputs have _widget and _subgraphSlot after construction', () => {
|
||||
const { subgraphNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
expect(subgraphNode.inputs).toHaveLength(1)
|
||||
expect(subgraphNode.inputs[0]._subgraphSlot).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
})
|
||||
|
||||
test('serialize() captures proxyWidgets from promotionStore for correct instance', () => {
|
||||
const { rootGraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
const store = usePromotionStore()
|
||||
|
||||
// The SubgraphNode should have promotions registered (from _setWidget)
|
||||
const promotions = store.getPromotions(rootGraph.id, subgraphNode.id)
|
||||
expect(promotions).toHaveLength(1)
|
||||
expect(promotions[0].sourceNodeId).toBe(String(interiorNode.id))
|
||||
expect(promotions[0].sourceWidgetName).toBe('seed')
|
||||
|
||||
// Serialize — should write proxyWidgets from promotionStore
|
||||
const serialized = subgraphNode.serialize()
|
||||
expect(serialized.properties?.proxyWidgets).toEqual([
|
||||
[String(interiorNode.id), 'seed']
|
||||
])
|
||||
})
|
||||
|
||||
test('second instance gets its own proxyWidgets from construction', () => {
|
||||
const { rootGraph, subgraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
const store = usePromotionStore()
|
||||
|
||||
// Original has promotions
|
||||
const promotions = store.getPromotions(rootGraph.id, subgraphNode.id)
|
||||
expect(promotions).toHaveLength(1)
|
||||
|
||||
// Create a second SubgraphNode with a DIFFERENT id (simulating clone)
|
||||
const cloneNode = createTestSubgraphNode(subgraph, { id: 999 })
|
||||
rootGraph.add(cloneNode)
|
||||
|
||||
// The clone gets proxyWidgets because _resolveInputWidget ran during
|
||||
// construction, registering promotions under its own id (999).
|
||||
const cloneSerialized = cloneNode.serialize()
|
||||
expect(cloneSerialized.properties?.proxyWidgets).toEqual([
|
||||
[String(interiorNode.id), 'seed']
|
||||
])
|
||||
})
|
||||
|
||||
test('serialize() preserves modified interior widget values', () => {
|
||||
const { interiorNode, subgraphNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
interiorNode.widgets![0].value = 999
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(interiorNode.widgets![0].value).toBe(999)
|
||||
})
|
||||
|
||||
test('asSerialisable() captures current widget values', () => {
|
||||
const { subgraph, interiorNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
interiorNode.widgets![0].value = 777
|
||||
|
||||
const exported = subgraph.asSerialisable()
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(777)
|
||||
})
|
||||
|
||||
test('direct serialize() preserves proxyWidgets and widget values', () => {
|
||||
const { subgraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
// Direct serialization captures correct proxyWidgets
|
||||
const originalSerialized = subgraphNode.serialize()
|
||||
expect(originalSerialized.properties?.proxyWidgets).toEqual([
|
||||
[String(interiorNode.id), 'seed']
|
||||
])
|
||||
|
||||
// Modify widget value
|
||||
interiorNode.widgets![0].value = 555
|
||||
|
||||
// Subgraph definition serialization should capture modified value
|
||||
const exported = subgraph.clone(true).asSerialisable()
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(555)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph copy roundtrip preserves state (#9976)', () => {
|
||||
test('serialized subgraph definition preserves modified widget values', () => {
|
||||
const { subgraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
interiorNode.widgets![0].value = 123
|
||||
|
||||
// Mimic _serializeItems clone path. Both serialized payloads are consumed
|
||||
// via cloneObject so the assertions below operate on snapshots, not live
|
||||
// references into the running subgraph.
|
||||
const serializedInstance = LiteGraph.cloneObject(subgraphNode.serialize())
|
||||
const serializedDef = LiteGraph.cloneObject(
|
||||
subgraph.clone(true).asSerialisable()
|
||||
)
|
||||
|
||||
// Mutate the live widget AFTER capture: the snapshot must remain at 123.
|
||||
// If serialize() ever started writing live references instead of snapshots,
|
||||
// this assertion would flip to -1.
|
||||
interiorNode.widgets![0].value = -1
|
||||
|
||||
expect(serializedInstance!.id).toBe(subgraphNode.id)
|
||||
|
||||
const exportedInterior = serializedDef!.nodes?.find(
|
||||
(n) => n.id === interiorNode.id
|
||||
)
|
||||
expect(exportedInterior?.widgets_values?.[0]).toBe(123)
|
||||
})
|
||||
|
||||
test('multiple instances: serialization order does not affect definition values', () => {
|
||||
const { rootGraph, subgraph, interiorNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
const subgraphNode2 = createTestSubgraphNode(subgraph, {
|
||||
id: 2,
|
||||
pos: [300, 0]
|
||||
})
|
||||
rootGraph.add(subgraphNode2)
|
||||
|
||||
interiorNode.widgets![0].value = 888
|
||||
|
||||
// Serialize both instances
|
||||
const firstNode = rootGraph.nodes.find(
|
||||
(n): n is SubgraphNode => n instanceof SubgraphNode && n.id === 1
|
||||
)!
|
||||
firstNode.serialize()
|
||||
subgraphNode2.serialize()
|
||||
|
||||
const exported = subgraph.clone(true).asSerialisable()
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(888)
|
||||
})
|
||||
})
|
||||
@@ -129,6 +129,8 @@
|
||||
"saveAnyway": "Save Anyway",
|
||||
"saving": "Saving",
|
||||
"no": "No",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"closeDialog": "Close dialog",
|
||||
@@ -1941,6 +1943,7 @@
|
||||
"upDirection": "Up Direction",
|
||||
"materialMode": "Material Mode",
|
||||
"showSkeleton": "Show Skeleton",
|
||||
"fitToViewer": "Fit to Viewer",
|
||||
"scene": "Scene",
|
||||
"model": "Model",
|
||||
"camera": "Camera",
|
||||
@@ -1997,6 +2000,14 @@
|
||||
"removeFile": "Remove HDRI",
|
||||
"showAsBackground": "Show as Background",
|
||||
"intensity": "Intensity"
|
||||
},
|
||||
"gizmo": {
|
||||
"label": "Gizmo",
|
||||
"toggle": "Gizmo",
|
||||
"translate": "Translate",
|
||||
"rotate": "Rotate",
|
||||
"scale": "Scale",
|
||||
"reset": "Reset Transform"
|
||||
}
|
||||
},
|
||||
"imageCrop": {
|
||||
@@ -2094,7 +2105,9 @@
|
||||
"failedToUploadBackgroundImage": "Failed to upload background image",
|
||||
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
|
||||
"failedToLoadHDRI": "Failed to load HDRI file",
|
||||
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
|
||||
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file.",
|
||||
"failedToToggleGizmo": "Failed to toggle gizmo",
|
||||
"failedToSetGizmoMode": "Failed to set gizmo mode"
|
||||
},
|
||||
"nodeErrors": {
|
||||
"render": "Node Render Error",
|
||||
|
||||
@@ -24,7 +24,7 @@ const i18n = createI18n({
|
||||
})
|
||||
|
||||
// Mock components with minimal functionality for business logic testing
|
||||
vi.mock('@/components/input/MultiSelect.vue', () => ({
|
||||
vi.mock('@/components/ui/multi-select/MultiSelect.vue', () => ({
|
||||
default: {
|
||||
name: 'MultiSelect',
|
||||
props: {
|
||||
@@ -46,7 +46,7 @@ vi.mock('@/components/input/MultiSelect.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/input/SingleSelect.vue', () => ({
|
||||
vi.mock('@/components/ui/single-select/SingleSelect.vue', () => ({
|
||||
default: {
|
||||
name: 'SingleSelect',
|
||||
props: {
|
||||
|
||||
@@ -59,9 +59,9 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MultiSelect from '@/components/input/MultiSelect.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import MultiSelect from '@/components/ui/multi-select/MultiSelect.vue'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
|
||||
import type { AssetMetadata } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { SelectOption } from '@/components/input/types'
|
||||
import type { SelectOption } from '@/components/ui/select/types'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { OwnershipFilterOption } from '@/platform/assets/types/filterTypes'
|
||||
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
|
||||
@@ -484,4 +484,56 @@ describe('useMediaAssetActions', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAssets - confirmation dialog item names', () => {
|
||||
beforeEach(() => {
|
||||
mockIsCloud.value = true
|
||||
mockGetAssetType.mockReturnValue('output')
|
||||
mockShowDialog.mockReset()
|
||||
})
|
||||
|
||||
it('should show user_metadata display names instead of hash filenames', () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const assets = [
|
||||
createMockAsset({
|
||||
id: 'asset-1',
|
||||
name: 'c885097ab185ced82f017bcbc98948918499f7480315fd5b928b5bb8d4951efc.png',
|
||||
user_metadata: { name: 'My Sunset Render' }
|
||||
}),
|
||||
createMockAsset({
|
||||
id: 'asset-2',
|
||||
name: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2.png',
|
||||
display_name: 'Portrait Variation'
|
||||
})
|
||||
]
|
||||
|
||||
void actions.deleteAssets(assets)
|
||||
|
||||
expect(mockShowDialog).toHaveBeenCalledTimes(1)
|
||||
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
|
||||
itemList: string[]
|
||||
}
|
||||
expect(dialogProps.itemList).toEqual([
|
||||
'My Sunset Render',
|
||||
'Portrait Variation'
|
||||
])
|
||||
})
|
||||
|
||||
it('should fall back to asset.name when no display name is available', () => {
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
const asset = createMockAsset({
|
||||
id: 'asset-3',
|
||||
name: 'fallback-image.png'
|
||||
})
|
||||
|
||||
void actions.deleteAssets(asset)
|
||||
|
||||
const dialogProps = mockShowDialog.mock.calls[0][0].props as {
|
||||
itemList: string[]
|
||||
}
|
||||
expect(dialogProps.itemList).toEqual(['fallback-image.png'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -595,7 +595,7 @@ export function useMediaAssetActions() {
|
||||
count: assetArray.length
|
||||
}),
|
||||
type: 'delete',
|
||||
itemList: assetArray.map((asset) => asset.name),
|
||||
itemList: assetArray.map((asset) => getAssetDisplayName(asset)),
|
||||
onConfirm: async () => {
|
||||
// Show loading overlay for all assets being deleted
|
||||
assetArray.forEach((asset) =>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<button
|
||||
v-for="(url, index) in imageUrls"
|
||||
:key="index"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:outline-none"
|
||||
class="focus-visible:ring-ring relative cursor-pointer overflow-hidden rounded-sm border-0 bg-transparent p-0 focus-visible:ring-2 focus-visible:outline-none"
|
||||
:aria-label="
|
||||
$t('g.viewImageOfTotal', {
|
||||
index: index + 1,
|
||||
@@ -193,7 +193,7 @@ const nodeOutputStore = useNodeOutputStore()
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const actionButtonClass =
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
'flex h-8 min-h-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground p-2 text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2'
|
||||
|
||||
type ViewMode = 'gallery' | 'grid'
|
||||
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
v-if="activeItem"
|
||||
:src="getItemSrc(activeItem)"
|
||||
:alt="getItemAlt(activeItem, activeIndex)"
|
||||
:class="
|
||||
cn(
|
||||
'h-auto w-full rounded-sm object-contain transition-opacity',
|
||||
showControls && 'opacity-50'
|
||||
)
|
||||
"
|
||||
class="h-auto w-full rounded-sm object-contain"
|
||||
@load="handleImageLoad"
|
||||
/>
|
||||
|
||||
@@ -238,7 +233,7 @@ const showNavButtons = computed(
|
||||
)
|
||||
|
||||
const actionButtonClass =
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-md transition-colors hover:bg-base-foreground/90'
|
||||
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors hover:bg-base-foreground/90'
|
||||
|
||||
const toggleButtonClass = actionButtonClass
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'size-full resize-none text-xs',
|
||||
!hideLayoutField && 'pt-5'
|
||||
!hideLayoutField && 'pt-5',
|
||||
// Avoid overflow-auto when idle to prevent per-textarea compositing layers.
|
||||
'overflow-hidden hover:overflow-auto focus:overflow-auto'
|
||||
)
|
||||
"
|
||||
:placeholder
|
||||
|
||||
@@ -23,10 +23,6 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
function captureWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
const id =
|
||||
selectedItems.size > 0 ? selectedItems.values().next().value : undefined
|
||||
@@ -36,7 +32,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
: dropdownItems.value.find((item) => item.id === id)?.name
|
||||
|
||||
modelValue.value = name
|
||||
captureWorkflowState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
@@ -109,7 +105,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
captureWorkflowState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import _ from 'es-toolkit/compat'
|
||||
import * as jsondiffpatch from 'jsondiffpatch'
|
||||
import log from 'loglevel'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
@@ -20,14 +20,37 @@ function clone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
const logger = log.getLogger('ChangeTracker')
|
||||
// Change to debug for more verbose logging
|
||||
logger.setLevel('info')
|
||||
|
||||
function isActiveTracker(tracker: ChangeTracker): boolean {
|
||||
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
|
||||
}
|
||||
|
||||
const reportedInactiveCalls = new Set<string>()
|
||||
|
||||
/**
|
||||
* Report a ChangeTracker method being called on an inactive tracker —
|
||||
* a lifecycle violation that usually indicates stale extension state or
|
||||
* an incorrect call ordering. Reports once per method per workflow per
|
||||
* session so the signal is not drowned out by hot-path invocations while
|
||||
* still distinguishing between workflows.
|
||||
*/
|
||||
function reportInactiveTrackerCall(method: string, workflowPath: string) {
|
||||
const key = `${method}:${workflowPath}`
|
||||
if (reportedInactiveCalls.has(key)) return
|
||||
reportedInactiveCalls.add(key)
|
||||
|
||||
console.warn(`${method}() called on inactive tracker for: ${workflowPath}`)
|
||||
|
||||
if (isDesktop) {
|
||||
Sentry.captureMessage(
|
||||
`ChangeTracker.${method}() called on inactive tracker`,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { workflow: workflowPath }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ChangeTracker {
|
||||
static MAX_HISTORY = 50
|
||||
/**
|
||||
@@ -77,7 +100,6 @@ export class ChangeTracker {
|
||||
// Do not reset the state if we are restoring.
|
||||
if (this._restoringState) return
|
||||
|
||||
logger.debug('Reset State')
|
||||
if (state) this.activeState = clone(state)
|
||||
this.initialState = clone(this.activeState)
|
||||
}
|
||||
@@ -107,10 +129,7 @@ export class ChangeTracker {
|
||||
*/
|
||||
deactivate() {
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'deactivate() called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
reportInactiveTrackerCall('deactivate', this.workflow.path)
|
||||
return
|
||||
}
|
||||
if (!this._restoringState) this.captureCanvasState()
|
||||
@@ -165,13 +184,6 @@ export class ChangeTracker {
|
||||
this.initialState,
|
||||
this.activeState
|
||||
)
|
||||
if (logger.getLevel() <= logger.levels.DEBUG && workflow.isModified) {
|
||||
const diff = ChangeTracker.graphDiff(
|
||||
this.initialState,
|
||||
this.activeState
|
||||
)
|
||||
logger.debug('Graph diff:', diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,19 +193,18 @@ export class ChangeTracker {
|
||||
* Calling this on an inactive tracker would capture the wrong graph.
|
||||
*/
|
||||
captureCanvasState() {
|
||||
const isUndoRedoing = this._restoringState
|
||||
const isInsideChangeTransaction = this.changeCount > 0
|
||||
if (
|
||||
!app.graph ||
|
||||
this.changeCount ||
|
||||
this._restoringState ||
|
||||
isInsideChangeTransaction ||
|
||||
isUndoRedoing ||
|
||||
ChangeTracker.isLoadingGraph
|
||||
)
|
||||
return
|
||||
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'captureCanvasState called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
reportInactiveTrackerCall('captureCanvasState', this.workflow.path)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -207,7 +218,6 @@ export class ChangeTracker {
|
||||
if (this.undoQueue.length > ChangeTracker.MAX_HISTORY) {
|
||||
this.undoQueue.shift()
|
||||
}
|
||||
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
|
||||
|
||||
this.activeState = currentState
|
||||
this.redoQueue.length = 0
|
||||
@@ -219,7 +229,7 @@ export class ChangeTracker {
|
||||
checkState() {
|
||||
if (!ChangeTracker._checkStateWarned) {
|
||||
ChangeTracker._checkStateWarned = true
|
||||
logger.warn(
|
||||
console.warn(
|
||||
'checkState() is deprecated — use captureCanvasState() instead.'
|
||||
)
|
||||
}
|
||||
@@ -248,22 +258,10 @@ export class ChangeTracker {
|
||||
|
||||
async undo() {
|
||||
await this.updateState(this.undoQueue, this.redoQueue)
|
||||
logger.debug(
|
||||
'Undo. Undo queue length:',
|
||||
this.undoQueue.length,
|
||||
'Redo queue length:',
|
||||
this.redoQueue.length
|
||||
)
|
||||
}
|
||||
|
||||
async redo() {
|
||||
await this.updateState(this.redoQueue, this.undoQueue)
|
||||
logger.debug(
|
||||
'Redo. Undo queue length:',
|
||||
this.undoQueue.length,
|
||||
'Redo queue length:',
|
||||
this.redoQueue.length
|
||||
)
|
||||
}
|
||||
|
||||
async undoRedo(e: KeyboardEvent) {
|
||||
@@ -337,7 +335,6 @@ export class ChangeTracker {
|
||||
|
||||
// If our active element is some type of input then handle changes after they're done
|
||||
if (ChangeTracker.bindInput(bindInputEl)) return
|
||||
logger.debug('captureCanvasState on keydown')
|
||||
changeTracker.captureCanvasState()
|
||||
})
|
||||
},
|
||||
@@ -347,25 +344,21 @@ export class ChangeTracker {
|
||||
window.addEventListener('keyup', () => {
|
||||
if (keyIgnored) {
|
||||
keyIgnored = false
|
||||
logger.debug('captureCanvasState on keyup')
|
||||
captureState()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clicking DOM elements (e.g. widgets)
|
||||
window.addEventListener('mouseup', () => {
|
||||
logger.debug('captureCanvasState on mouseup')
|
||||
captureState()
|
||||
})
|
||||
|
||||
// Handle prompt queue event for dynamic widget changes
|
||||
api.addEventListener('promptQueued', () => {
|
||||
logger.debug('captureCanvasState on promptQueued')
|
||||
captureState()
|
||||
})
|
||||
|
||||
api.addEventListener('graphCleared', () => {
|
||||
logger.debug('captureCanvasState on graphCleared')
|
||||
captureState()
|
||||
})
|
||||
|
||||
@@ -373,7 +366,6 @@ export class ChangeTracker {
|
||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||
const v = processMouseUp.apply(this, [e])
|
||||
logger.debug('captureCanvasState on processMouseUp')
|
||||
captureState()
|
||||
return v
|
||||
}
|
||||
@@ -390,7 +382,6 @@ export class ChangeTracker {
|
||||
callback(v)
|
||||
captureState()
|
||||
}
|
||||
logger.debug('captureCanvasState on prompt')
|
||||
return prompt.apply(this, [title, value, extendedCallback, event])
|
||||
}
|
||||
|
||||
@@ -398,7 +389,6 @@ export class ChangeTracker {
|
||||
const close = LiteGraph.ContextMenu.prototype.close
|
||||
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
|
||||
const v = close.apply(this, [e])
|
||||
logger.debug('captureCanvasState on contextMenuClose')
|
||||
captureState()
|
||||
return v
|
||||
}
|
||||
@@ -501,25 +491,4 @@ export class ChangeTracker {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static graphDiff(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
|
||||
function sortGraphNodes(graph: ComfyWorkflowJSON) {
|
||||
return {
|
||||
links: graph.links,
|
||||
floatingLinks: graph.floatingLinks,
|
||||
reroutes: graph.reroutes,
|
||||
groups: graph.groups,
|
||||
extra: graph.extra,
|
||||
definitions: graph.definitions,
|
||||
subgraphs: graph.subgraphs,
|
||||
nodes: graph.nodes.sort((a, b) => {
|
||||
if (typeof a.id === 'number' && typeof b.id === 'number') {
|
||||
return a.id - b.id
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
}
|
||||
return jsondiffpatch.diff(sortGraphNodes(a), sortGraphNodes(b))
|
||||
}
|
||||
}
|
||||
|
||||
43
src/services/litegraphService.test.ts
Normal file
43
src/services/litegraphService.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: undefined },
|
||||
ComfyApp: class {}
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
describe('useLitegraphService().getCanvasCenter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns origin when canvas is not yet initialised', () => {
|
||||
Reflect.set(app, 'canvas', undefined)
|
||||
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(center).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('returns origin when canvas exists but ds.visible_area is missing', () => {
|
||||
Reflect.set(app, 'canvas', { ds: {} })
|
||||
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(center).toEqual([0, 0])
|
||||
})
|
||||
|
||||
it('returns the visible-area centre once the canvas is ready', () => {
|
||||
Reflect.set(app, 'canvas', {
|
||||
ds: { visible_area: [10, 20, 200, 100] }
|
||||
})
|
||||
|
||||
const center = useLitegraphService().getCanvasCenter()
|
||||
|
||||
expect(center).toEqual([110, 70])
|
||||
})
|
||||
})
|
||||
@@ -216,6 +216,9 @@ class Load3dService {
|
||||
async copyLoad3dState(source: Load3d, target: Load3d) {
|
||||
const sourceModel = source.modelManager.currentModel
|
||||
|
||||
const gizmoWasEnabled = target.getGizmoManager().isEnabled()
|
||||
target.getGizmoManager().detach()
|
||||
|
||||
if (sourceModel) {
|
||||
// Remove existing model from target scene before adding new one
|
||||
const existingModel = target.getModelManager().currentModel
|
||||
@@ -256,6 +259,36 @@ class Load3dService {
|
||||
source.getModelManager().appliedTexture
|
||||
}
|
||||
|
||||
const sourceInitial = source.getGizmoManager().getInitialTransform()
|
||||
modelClone.position.set(
|
||||
sourceInitial.position.x,
|
||||
sourceInitial.position.y,
|
||||
sourceInitial.position.z
|
||||
)
|
||||
modelClone.rotation.set(
|
||||
sourceInitial.rotation.x,
|
||||
sourceInitial.rotation.y,
|
||||
sourceInitial.rotation.z
|
||||
)
|
||||
modelClone.scale.set(
|
||||
sourceInitial.scale.x,
|
||||
sourceInitial.scale.y,
|
||||
sourceInitial.scale.z
|
||||
)
|
||||
|
||||
target.getGizmoManager().setupForModel(modelClone)
|
||||
const gizmoTransform = source.getGizmoTransform()
|
||||
target.applyGizmoTransform(
|
||||
gizmoTransform.position,
|
||||
gizmoTransform.rotation,
|
||||
gizmoTransform.scale
|
||||
)
|
||||
const shouldEnable =
|
||||
gizmoWasEnabled || source.getGizmoManager().isEnabled()
|
||||
if (shouldEnable) {
|
||||
target.setGizmoEnabled(true)
|
||||
}
|
||||
|
||||
// Copy animation state
|
||||
if (source.hasAnimations()) {
|
||||
target.animationManager.setupModelAnimations(
|
||||
|
||||
@@ -155,6 +155,95 @@ describe('useWidgetValueStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrCreateWidget', () => {
|
||||
it('creates a new entry when widget does not exist', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.getOrCreateWidget(graphA, 'node-1', 'option1', 'foo')
|
||||
|
||||
expect(state.nodeId).toBe('node-1')
|
||||
expect(state.name).toBe('option1')
|
||||
expect(state.value).toBe('foo')
|
||||
})
|
||||
|
||||
it('returns existing entry without overwriting value', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'option1', 'string', 'bar'))
|
||||
|
||||
const state = store.getOrCreateWidget(
|
||||
graphA,
|
||||
'node-1',
|
||||
'option1',
|
||||
'should-not-overwrite'
|
||||
)
|
||||
expect(state.value).toBe('bar')
|
||||
})
|
||||
|
||||
it('is idempotent — repeated calls return same reactive entry', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const first = store.getOrCreateWidget(graphA, 'node-1', 'w', 'a')
|
||||
const second = store.getOrCreateWidget(graphA, 'node-1', 'w', 'b')
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect(first.value).toBe('a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydration transactions', () => {
|
||||
it('beginHydration / isHydrating / commitHydration lifecycle', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
expect(store.isHydrating('node-1')).toBe(false)
|
||||
|
||||
store.beginHydration('node-1')
|
||||
expect(store.isHydrating('node-1')).toBe(true)
|
||||
expect(store.isHydrating('node-2')).toBe(false)
|
||||
|
||||
store.commitHydration('node-1')
|
||||
expect(store.isHydrating('node-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('commitHydration fires registered callbacks', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const calls: string[] = []
|
||||
|
||||
store.beginHydration('node-1')
|
||||
store.onHydrationComplete('node-1', () => calls.push('a'))
|
||||
store.onHydrationComplete('node-1', () => calls.push('b'))
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
|
||||
store.commitHydration('node-1')
|
||||
expect(calls).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('onHydrationComplete fires immediately when not hydrating', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const calls: string[] = []
|
||||
|
||||
store.onHydrationComplete('node-1', () => calls.push('immediate'))
|
||||
expect(calls).toEqual(['immediate'])
|
||||
})
|
||||
|
||||
it('commitHydration is safe to call when not hydrating', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(() => store.commitHydration('node-1')).not.toThrow()
|
||||
})
|
||||
|
||||
it('hydration is node-scoped — independent per node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
store.beginHydration('node-1')
|
||||
store.beginHydration('node-2')
|
||||
|
||||
store.commitHydration('node-1')
|
||||
expect(store.isHydrating('node-1')).toBe(false)
|
||||
expect(store.isHydrating('node-2')).toBe(true)
|
||||
|
||||
store.commitHydration('node-2')
|
||||
expect(store.isHydrating('node-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates widget states by graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
@@ -34,8 +34,12 @@ export interface WidgetState<
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
type HydrationCallback = () => void
|
||||
|
||||
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
|
||||
const hydratingNodes = new Set<NodeId>()
|
||||
const hydrationCallbacks = new Map<NodeId, HydrationCallback[]>()
|
||||
|
||||
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
|
||||
const widgetStates = graphWidgetStates.value.get(graphId)
|
||||
@@ -57,6 +61,8 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
widgetStates.set(key, state)
|
||||
// Return the reactive proxy from the map (not the raw input) so that
|
||||
// callers who hold a reference see Vue-tracked mutations.
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
@@ -76,6 +82,53 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
}
|
||||
|
||||
function getOrCreateWidget(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
defaultValue?: unknown
|
||||
): WidgetState {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(nodeId, widgetName)
|
||||
const existing = widgetStates.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const state: WidgetState = {
|
||||
nodeId,
|
||||
name: widgetName,
|
||||
type: 'string',
|
||||
value: defaultValue,
|
||||
options: {}
|
||||
}
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key)!
|
||||
}
|
||||
|
||||
function beginHydration(nodeId: NodeId): void {
|
||||
hydratingNodes.add(nodeId)
|
||||
}
|
||||
|
||||
function commitHydration(nodeId: NodeId): void {
|
||||
hydratingNodes.delete(nodeId)
|
||||
const callbacks = hydrationCallbacks.get(nodeId)
|
||||
if (!callbacks) return
|
||||
|
||||
hydrationCallbacks.delete(nodeId)
|
||||
for (const cb of callbacks) cb()
|
||||
}
|
||||
|
||||
function isHydrating(nodeId: NodeId): boolean {
|
||||
return hydratingNodes.has(nodeId)
|
||||
}
|
||||
|
||||
function onHydrationComplete(nodeId: NodeId, callback: HydrationCallback) {
|
||||
if (!hydratingNodes.has(nodeId)) return callback()
|
||||
|
||||
const existing = hydrationCallbacks.get(nodeId) ?? []
|
||||
if (!existing.includes(callback)) existing.push(callback)
|
||||
hydrationCallbacks.set(nodeId, existing)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
}
|
||||
@@ -83,7 +136,12 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getOrCreateWidget,
|
||||
getNodeWidgets,
|
||||
beginHydration,
|
||||
commitHydration,
|
||||
isHydrating,
|
||||
onHydrationComplete,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { graphToPrompt } from './executionUtil'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
describe('graphToPrompt', () => {
|
||||
it('excludes nodes with isVirtualNode from API output', async () => {
|
||||
const graph = new LGraph()
|
||||
const realNode = new LGraphNode('RealNode')
|
||||
realNode.comfyClass = 'KSampler'
|
||||
graph.add(realNode)
|
||||
|
||||
const virtualNode = new LGraphNode('VirtualNode')
|
||||
virtualNode.isVirtualNode = true
|
||||
virtualNode.comfyClass = 'Note'
|
||||
graph.add(virtualNode)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(output[String(virtualNode.id)]).toBeUndefined()
|
||||
expect(output[String(realNode.id)]).toBeDefined()
|
||||
expect(output[String(realNode.id)].class_type).toBe('KSampler')
|
||||
})
|
||||
|
||||
it('produces empty output when all nodes are virtual', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const note = new LGraphNode('Note')
|
||||
note.isVirtualNode = true
|
||||
note.comfyClass = 'Note'
|
||||
graph.add(note)
|
||||
|
||||
const mdNote = new LGraphNode('MarkdownNote')
|
||||
mdNote.isVirtualNode = true
|
||||
mdNote.comfyClass = 'MarkdownNote'
|
||||
graph.add(mdNote)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(Object.keys(output)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes virtual nodes in workflow JSON for save fidelity', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const note = new LGraphNode('Note')
|
||||
note.isVirtualNode = true
|
||||
note.comfyClass = 'Note'
|
||||
graph.add(note)
|
||||
|
||||
const realNode = new LGraphNode('RealNode')
|
||||
realNode.comfyClass = 'KSampler'
|
||||
graph.add(realNode)
|
||||
|
||||
const { workflow, output } = await graphToPrompt(graph)
|
||||
|
||||
expect(
|
||||
workflow.nodes.some((n) => n.id === note.id),
|
||||
'Workflow JSON should preserve virtual nodes by ID'
|
||||
).toBe(true)
|
||||
expect(output[String(note.id)]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves multiple non-virtual nodes', async () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
const node1 = new LGraphNode('Node1')
|
||||
node1.comfyClass = 'KSampler'
|
||||
graph.add(node1)
|
||||
|
||||
const node2 = new LGraphNode('Node2')
|
||||
node2.comfyClass = 'SaveImage'
|
||||
graph.add(node2)
|
||||
|
||||
const { output } = await graphToPrompt(graph)
|
||||
|
||||
expect(Object.keys(output)).toHaveLength(2)
|
||||
expect(output[String(node1.id)].class_type).toBe('KSampler')
|
||||
expect(output[String(node2.id)].class_type).toBe('SaveImage')
|
||||
})
|
||||
})
|
||||
@@ -157,7 +157,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import SingleSelect from '@/components/input/SingleSelect.vue'
|
||||
import SingleSelect from '@/components/ui/single-select/SingleSelect.vue'
|
||||
import SearchAutocomplete from '@/components/ui/search-input/SearchAutocomplete.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
|
||||
Reference in New Issue
Block a user