Compare commits

..

21 Commits

Author SHA1 Message Date
bymyself
824ff2b264 test: round-trip serialized payload in subgraph copy roundtrip test
The 'serialized subgraph definition preserves modified widget values'
test previously called subgraphNode.serialize() but discarded the
result, and asserted against subgraph.clone(true).asSerialisable() which
reads live widget state — meaning the assertion could pass even if the
copy/paste serialize path regressed.

Now consumes both serialized payloads via LiteGraph.cloneObject (the
same call _serializeItems uses), then mutates the live widget value
to -1 after capture. The assertion still expects 123 from the snapshot,
so a regression that produced live references rather than snapshots
would fail the test.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945448212
2026-05-03 23:03:52 -07:00
bymyself
4b753407e5 test: use shared subgraph helpers in SubgraphNode.serialize.test.ts
Replaces manual SubgraphNode/instanceData construction with the shared
createTestSubgraphNode helper, and switches to subgraphTest (Pinia
auto-setup fixture) so the file matches the conventions of the other
subgraph test files.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945026556
2026-05-03 23:00:31 -07:00
Christian Byrne
744b6a8956 fix: wrap configure() widget restoration in hydration transaction (#10201)
Wraps `LGraphNode.configure()`'s widget-value restoration phase in a
hydration transaction (`beginHydration` / `commitHydration`), preventing
derived-state callbacks (e.g. CustomCombo's `updateCombo`) from firing
before all widget values are restored.

```mermaid
flowchart LR
    subgraph core["Core Layer"]
        LGN["LGraphNode.configure()"]
        WVS["widgetValueStore"]
    end

    subgraph extensions["Extension Layer"]
        CW["CustomCombo\n(customWidgets.ts)"]
        PN["PrimitiveNode\n(widgetInputs.ts)"]
    end

    LGN -- "beginHydration /\ncommitHydration" --> WVS
    CW -- "isHydrating() guard" --> WVS
    CW -- "onHydrationComplete()" --> WVS
    PN -- "serialize() override\npreserves widgets_values" --> LGN
```

```mermaid
sequenceDiagram
    participant Caller as Paste / Load
    participant Node as LGraphNode.configure()
    participant Store as widgetValueStore
    participant Widget as Widget Setters
    participant Combo as CustomCombo.updateCombo

    Caller->>Node: configure(serializedData)
    Node->>Store: beginHydration(nodeId)
    activate Store
    Note over Store: hydratingNodes.add(nodeId)

    loop For each widget
        Node->>Widget: widget.value = restored_value
        Widget->>Combo: updateCombo() triggered
        Combo->>Store: isHydrating(nodeId)?
        Store-->>Combo: true - early return
        Note over Combo: Side effect suppressed
    end

    Node->>Node: onConfigure(info)
    Note over Node: Extensions register onHydrationComplete callbacks

    Node->>Store: commitHydration(nodeId)
    Note over Store: hydratingNodes.delete(nodeId)
    Store->>Combo: fire queued callbacks
    Note over Combo: updateCombo() runs once with all values present
    deactivate Store
```

```mermaid
flowchart TB
    subgraph before["Before: Race Condition"]
        direction TB
        B1["configure starts"] --> B2["widget1.value = optionA"]
        B2 --> B3["updateCombo fires immediately"]
        B3 --> B4["values list incomplete,\ncomboWidget.value reset"]
        B4 --> B5["widget2.value = optionB"]
        B5 --> B6["updateCombo fires again"]
        B6 --> B7["Combo has wrong value"]
    end

    subgraph after["After: Hydration Transaction"]
        direction TB
        A1["configure starts"] --> A2["beginHydration nodeId"]
        A2 --> A3["widget1.value = optionA"]
        A3 --> A4["updateCombo skipped"]
        A4 --> A5["widget2.value = optionB"]
        A5 --> A6["updateCombo skipped"]
        A6 --> A7["commitHydration nodeId"]
        A7 --> A8["updateCombo runs once,\nall values present"]
    end
```

- **`LGraphNode.configure()`**: Wraps widget restoration + `onConfigure`
in `beginHydration`/`commitHydration` with `try/finally` for exception
safety
- **`PrimitiveNode.serialize()`**: Adds override to preserve
`widgets_values` when widgets are dynamically disconnected
- **`customWidgets.ts`**: Simplifies CustomCombo's `onConfigure` to use
`onHydrationComplete()` instead of manually managing hydration state
- **3 new tests**: Hydration transaction wrapping, `onHydrationComplete`
callback firing, and exception safety

Stacked on #10010. Implements Design A from the widget state
architecture RFC.
2026-04-18 21:40:31 -07:00
bymyself
ebc5108d90 test: add Playwright e2e tests for SubgraphNode copy-paste
- Copy-paste SubgraphNode preserves promoted widgets
- Pasted SubgraphNode retains proxyWidgets in serialized properties
- Interior widget values survive copy-paste round-trip through loadGraphData

Covers the _serializeItems serialize-not-clone path introduced in this PR.
Requested by DrJKL and CodeRabbit.
2026-04-18 21:37:30 -07:00
bymyself
c5d7119ff2 fix: use barrel import for SubgraphNode, tighten clone test assertion
- Import SubgraphNode from litegraph barrel to avoid circular deps
- Rename test: 'clone with different id' → 'second instance gets its own proxyWidgets'
- Tighten assertion from toBeDefined() to toEqual() with expected value

Addresses CodeRabbit and dante01yoon review feedback.
2026-04-18 21:37:30 -07:00
GitHub Action
40b8d4240c [automated] Apply ESLint and Oxfmt fixes 2026-04-18 21:37:30 -07:00
bymyself
ef13872f63 test: use shared createTestSubgraph helper, fix test name
- Build on createTestSubgraph from __fixtures__/subgraphHelpers instead
  of standalone boilerplate (dante01yoon review feedback)
- Rename misleading test name to match actual assertion (coderabbit)

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945026556
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945291401
2026-04-18 21:37:30 -07:00
bymyself
66e75ed1e9 fix: use direct serialization instead of clone in _serializeItems
Per review feedback from @AustinMroz: replace clone().serialize() with
direct serialization for all nodes, clearing links manually. Avoids the
clone->serialize gap where transient nodes lose external state.
2026-04-18 21:37:30 -07:00
bymyself
8eb2d1d078 fix: prevent subgraph state pollution during copy-paste (#9976)
Root cause: _serializeItems used clone().serialize() for SubgraphNode,
creating a transient clone with a new ID. SubgraphNode.serialize() then
queried promotionStore with the wrong ID, producing incorrect
proxyWidgets metadata in the clipboard.

Fix: bypass clone().serialize() for SubgraphNode instances; serialize
the original directly and manually clear links in the serialized data.

Includes 8 unit tests verifying correct serialization behavior and
demonstrating the prior failure mode (ID mismatch during cloning).
2026-04-18 21:37:29 -07:00
Christian Byrne
a3893a593d refactor: move select components from input/ to ui/ component library (#11378)
*PR Created by the Glary-Bot Agent*

---

## Summary

Reconciles `src/components/input/` (older select components) into
`src/components/ui/` (internal component library), eliminating the
separate `input/` directory entirely.

## Changes

- **Move MultiSelect** →
`src/components/ui/multi-select/MultiSelect.vue`
- **Move SingleSelect** →
`src/components/ui/single-select/SingleSelect.vue`
- **Extract shared resources** → `src/components/ui/select/types.ts`
(SelectOption type) and `src/components/ui/select/select.variants.ts`
(CVA styling variants)
- **Update 7 consuming files** to use new import paths
- **Update 1 test file** (AssetFilterBar.test.ts mock paths)
- **Move stories and tests** alongside their components
- **Delete `src/components/input/`** directory

## Context

The `input/` directory contained only MultiSelect and SingleSelect — two
well-built components that already used the same stack as `ui/` (Reka
UI, CVA, Tailwind 4, Composition API). MultiSelect even imported
`ui/button/Button.vue`. Moving them into `ui/` removes the split and
consolidates all reusable components in one place.

No API changes — all component props, slots, events, and behavior are
preserved exactly.

## Verification

- `pnpm typecheck` 
- `pnpm build` 
- `pnpm lint` (stylelint + oxlint + eslint) 
- All 15 relevant tests pass (MultiSelect: 5, SingleSelect: 2,
AssetFilterBar: 8) 
- `pnpm knip` — no dead exports 
- No stale `@/components/input/` references remain 
- Pre-commit hooks pass 
- Git detected all moves as renames (97-100% similarity)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11378-refactor-move-select-components-from-input-to-ui-component-library-3476d73d3650810e99b4c3e0842e67f3)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 20:00:34 -07:00
Terry Jia
deba72e7a0 gizmo controls (#11274)
## Summary
Add Gizmo transform controls to load3d

- Remove automatic model normalization (scale + center) on load; models
now appear at their original transform. The previous auto-normalization
conflicted with gizmo controls — applying scale/position on load made it
impossible to track and reset the user's intentional transform edits vs.
the system's normalization
- Add a manual Fit to Viewer button that performs the same normalization
on demand, giving users explicit control
- Add Gizmo Controls (translate/rotate) for interactive model
manipulation with full state persistence across node properties, viewer
dialog, and model reloads
- Gizmo transform state is excluded from scene capture and recording to
keep outputs clean

## Motivation
The gizmo system is a prerequisite for these potential features:
- Custom cameras — user-placed cameras in the scene need transform
gizmos for precise positioning and orientation
- Custom lights — scene lighting setup requires the ability to
interactively position and aim light sources
- Multi-object scene composition — positioning multiple models relative
to each other requires per-object transform controls
- Pose editor — skeletal pose editing depends on the same transform
infrastructure to manipulate individual bones/joints

Auto-normalization was removed because it silently mutated model
transforms on load, making it impossible to distinguish between the
original model pose and user edits. This broke gizmo reset (which needs
to know the "clean" state) and would corrupt round-trip transform
persistence.

## Screenshots (if applicable)

https://github.com/user-attachments/assets/621ea559-d7c8-4c5a-a727-98e6a4130b66

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11274-gizmo-controls-3436d73d365081c38357c2d58e49c558)
by [Unito](https://www.unito.io)
2026-04-18 22:45:06 -04:00
Rizumu Ayaka
3db0eac353 perf: textarea widget layer composition (#10804)
## Summary

I noticed that nodes using textarea for user input, which contain long
user-entered text, require scrolling within a single node.

Having 40 such textarea nodes in a test canvas is enough to cause lag
(20fps). In contrast, a control group using regular nodes can handle up
to 500 nodes without lag (60fps).

the numerous scrolling text widgets in test workflows are the main
source of performance pressure. Each scrolling text input box imposes
independent layout and layering pressure.

I initially tried more complex solutions to fix this issue, like virtual
scrolling. However, I found that a simple CSS modification was
sufficient and effective. Even when I quadrupled the problematic number
of nodes on my M5 MacBook Air, it remained smooth.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10804-perf-textarea-widget-layer-composition-3356d73d3650814da75adec266d7cad9)
by [Unito](https://www.unito.io)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-04-19 02:29:00 +00:00
Dante
4c7729ee0b fix: remove hover dimming overlay on image nodes (#11296)
## Summary

Remove the black opacity/dimming overlay on image node hover and add
shadows to action buttons for visibility against light backgrounds.

## Changes

- **What**: Remove `opacity-50` dimming on hover in
`DisplayCarousel.vue`, remove `transition-opacity hover:opacity-80` from
grid thumbnails in `ImagePreview.vue`, add `shadow-md` to action buttons
in `ImagePreview.vue`. Applies to Save Image, Load Image, Preview Image,
and all nodes using these shared image components.

## Review Focus

Button shadows (`shadow-md`) should provide sufficient contrast against
light image backgrounds without needing the dimming overlay.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11296-fix-remove-hover-dimming-overlay-on-image-nodes-3446d73d36508193bb5cc27d431014fd)
by [Unito](https://www.unito.io)
2026-04-18 22:40:11 +00:00
Dante
40083d593b test: cover Button, Textarea, Slider components (#11325)
Closes coverage gaps in \`src/components/ui/\` as part of the unit-test
backfill. Uses \`@testing-library/vue\` +
\`@testing-library/user-event\` for user-centric, behavioral assertions.

## Testing focus

Three Reka-UI primitives. The challenge is testing the contract — not
the library internals — given happy-dom's gaps and Reka's
\`useMounted()\`-based async initialization.

### \`Button\` (7 tests)

- Slot rendering + click event propagation.
- \`loading=true\`: three invariants hold **simultaneously** — slot
hidden, \`pi-spin\` spinner present, button is \`toBeDisabled()\`.
- \`disabled=true\` alone: button disabled, no spinner.
- \`as="a"\`: polymorphic root tag (Reka \`Primitive\`'s \`as\` prop
switches the rendered element).
- Variant class pass-through: **one** deliberate style assertion because
the variant-system wiring is part of the component's public contract. No
other styling/class checks (AGENTS.md bans class-based tests).

### \`Textarea\` (6 tests)

- \`v-model\` two-way binding: \`user.type()\` updates the bound ref;
initial value populates the textarea.
- \`disabled\` asserted **behaviorally** — typing is blocked when
disabled, not just the attribute presence.
- Pass-through: \`placeholder\`, \`rows\`, \`class\`.

### \`Slider\` (8 tests)

- Thumb count matches \`modelValue.length\` (range support).
- ARIA: \`aria-valuemin\` / \`aria-valuemax\` / \`aria-valuenow\`.
**Caveat:** Reka's \`SliderRoot\` uses \`useMounted()\`, so
\`aria-valuenow\` is absent on the first render tick. The tests use a
two-tick \`flush()\` helper (\`await nextTick()\` twice) to wait it out
— no mocking of Reka required.
- Keyboard drag: \`user.keyboard('{ArrowRight}')\` / \`'{ArrowLeft}'\`
moves the value; with \`step: 10\` starting from 50, ArrowRight produces
exactly \`[60]\`.
- \`disabled\` → no emit on keyboard events.

### Reka integration limit

Pointer-driven \`slide-start\` / \`slide-end\` gestures in happy-dom
would require faking \`getBoundingClientRect\` and \`setPointerCapture\`
— that crosses into mocking Reka internals. Keyboard-drag paths are
covered instead (the user-facing contract); the \`pressed\` CSS state is
exercised implicitly by surviving a full mount + update cycle.

## Principles applied

- No mocks of Vue, Reka, or \`@vueuse/core\`.
- Queries via \`getByRole\` / \`getByLabelText\`; **no** class-name or
Tailwind-token queries (per AGENTS.md).
- All 21 tests pass; typecheck/lint/format clean. Test-only; no
production code touched.
2026-04-18 22:36:16 +00:00
Dante
7089a7d1a0 fix: show asset display names in bulk delete confirmation (#11321)
## Summary
Bulk-delete confirmation on Comfy Cloud listed raw SHA-256 filenames,
making the modal impossible to use to verify what would be deleted.

## Changes
- **What**: `useMediaAssetActions.deleteAssets` now maps each asset
through `getAssetDisplayName`, so the confirmation's `itemList` matches
the user-assigned names shown in the left media panel
(`MediaAssetCard`).
- **Tests**: Added two regression tests covering `user_metadata.name` /
`display_name` resolution and the `asset.name` fallback.

## Review Focus
- Parity with `MediaAssetCard` display: we reuse the same
`getAssetDisplayName` helper; extension stripping (via
`getFilenameDetails`) is not applied in the modal since file extensions
are useful context when confirming deletions.

Reported in Slack:
https://comfy-organization.slack.com/archives/C0A4XMHANP3/p1776383570015289

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11321-fix-show-asset-display-names-in-bulk-delete-confirmation-3456d73d36508108a3d5f2290ca39e18)
by [Unito](https://www.unito.io)
2026-04-18 22:35:39 +00:00
Christian Byrne
3b4811b00d feat: deploy E2E coverage HTML report to GitHub Pages (#11291)
## Summary

Browsable E2E coverage report deployed to GitHub Pages on every main
merge, replacing the current workflow of downloading LCOV artifacts and
using an external viewer.

## Changes

- **What**: After merging shard LCOVs, run `genhtml` to produce an HTML
report with per-file line coverage. On `main`, deploy to GitHub Pages
via `actions/deploy-pages`. For PR runs, the HTML report is still
available as the `e2e-coverage-html` artifact.
- **Dependencies**: None new — `genhtml` is part of the `lcov` package
already installed in the workflow.

## Review Focus

- **GitHub Pages must be enabled**: Settings → Pages → Source → "GitHub
Actions". Without this the deploy job will fail silently.
- The deploy job only runs for `main` branch (`if:
github.event.workflow_run.head_branch == 'main'`) so PR coverage doesn't
clobber the deployed report.
- Added `pages: write` and `id-token: write` permissions to the workflow
for the Pages deployment.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11291-feat-deploy-E2E-coverage-HTML-report-to-GitHub-Pages-3446d73d36508136ba6fd806690c9cfc)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-18 15:40:59 -07:00
jaeone94
b756545f59 refactor: clean up ChangeTracker logging, guards, and redundant widget wrapper (#11328)
## Summary

Follow-ups to PR #10816. Bundles four review items left open after that
PR merged — three inside `ChangeTracker` itself and one in the widget
composable that wraps it.

### What changed

- **Removed all `loglevel` logging from `src/scripts/changeTracker.ts`**
— the logger was set to `info`, so every `logger.debug` call was dead
code at runtime. `logger.warn` calls were replaced with direct
reporting. The only-downstream dead code (`graphDiff` helper) and its
sole dependency (`jsondiffpatch`) are also removed.
- **Named the `captureCanvasState()` guard conditions** —
`isUndoRedoing` and `isInsideChangeTransaction` now carry the intent
that the inline `_restoringState` / `changeCount > 0` expressions used
to obscure.
- **Surfaced lifecycle violations through a single reporting helper** —
`reportInactiveTrackerCall()` logs `console.warn` once per method per
session and, on Desktop, emits a `Sentry.addBreadcrumb` with the
offending workflow path. `deactivate()` and `captureCanvasState()` share
this path so the same invariant is reported consistently.
- **Inlined `captureWorkflowState` wrapper in `useWidgetSelectActions`**
— the private helper forwarded to `changeTracker.captureCanvasState()`
with no added logic. Both call sites now invoke the change tracker
directly.

### Issues fixed

- Fixes #11249
- Fixes #11259
- Fixes #11258
- Fixes #11248

### Test plan

- [x] `pnpm test:unit src/scripts/changeTracker.test.ts` — 16 tests pass
- [x] `pnpm test:unit
src/renderer/extensions/vueNodes/widgets/composables/useWidgetSelectActions.test.ts`
— 6 tests pass
- [x] `pnpm typecheck`
- [x] `pnpm lint`
- [x] `pnpm format`
2026-04-18 22:28:05 +00:00
Alexander Brown
da91bdc957 fix: persist middle-click reroute node setting across reloads (#11362)
*PR Created by the Glary-Bot Agent*

---

## Summary

- Remove hardcoded `LiteGraph.middle_click_slot_add_default_node = true`
from `slotDefaults` extension `init()` that unconditionally overrode the
user's persisted preference on every page load
- Add E2E regression test verifying both the setting store value and the
LiteGraph runtime flag persist through page reload

## Root Cause

The `Comfy.SlotDefaults` extension's `init()` method (in
`slotDefaults.ts`) contained a hardcoded
`LiteGraph.middle_click_slot_add_default_node = true` from the original
JS→TS conversion (July 2024). When `Comfy.Node.MiddleClickRerouteNode`
was later made configurable in v1.3.42, this line was never removed.
Since extension `init()` runs **after** `useLitegraphSettings()` syncs
the stored value, the hardcoded assignment overwrote the user's
preference on every reload.

## Changes

| File | Change |
|------|--------|
| `src/extensions/core/slotDefaults.ts` | Remove line 21
(`LiteGraph.middle_click_slot_add_default_node = true`) |
| `browser_tests/tests/dialogs/settingsDialog.spec.ts` | Add reload
persistence test asserting both store value and LiteGraph global |

The setting default (`true`) is already properly managed by
`coreSettings.ts` and reactively synced via `useLitegraphSettings.ts`,
so removing the hardcoded line preserves existing default behavior while
allowing user overrides to persist.

## Screenshots

![Setting shown as enabled (default
state)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970358-dcd6bd51-00c8-4ed4-86ce-0f1a89576f52.png)

![Setting toggled off by
user](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528970719-fb1f587f-964d-4e6c-954e-3145812badaf.png)

![Setting correctly persists as off after page reload (with fix
applied)](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/a820b6dee65aa3491b51c6e86d1e803bdf53309234e9591bd78b5a7c83d4684c/pr-images/1776528971113-36b577cb-5fd1-445d-8c8f-3ea8f6f46326.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11362-fix-persist-middle-click-reroute-node-setting-across-reloads-3466d73d365081ef8692dbd0619c8594)
by [Unito](https://www.unito.io)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-04-18 21:29:44 +00:00
Christian Byrne
cf3006f82c fix: reduce noise in coverage Slack notifications (#11283)
## Summary

Suppress low-signal coverage Slack notifications that show +0.0% or
-0.0% deltas.

## Changes

- **What**: Add `MIN_DELTA` threshold (0.05%) so only meaningful
improvements trigger notifications. Only display rows for metrics that
actually improved (no more E2E row showing -0.0% alongside a real unit
improvement). Fix `formatDelta` to clamp near-zero values to `+0.0%`
instead of showing `-0.0%`.
- 4 of the first 6 notifications posted were noise (+0.0% deltas from
instrumentation jitter). With this change, only 2 of 6 would have been
posted — both showing real improvements.

## Review Focus

The `MIN_DELTA` value of 0.05 means any delta that rounds to ±0.0% at 1
decimal place is suppressed. This matches the display precision so users
never see +0.0% notifications.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11283-fix-reduce-noise-in-coverage-Slack-notifications-3436d73d3650819ab3bcfebdb748ac8b)
by [Unito](https://www.unito.io)
2026-04-18 13:28:32 -07:00
pythongosssss
be2d757c47 test: add regression test for getCanvasCenter null guard (#8399) (#11271)
## Summary

Add a regression test for #8399 (null check in `getCanvasCenter` to
prevent crash on asset insert). The fix in
`src/services/litegraphService.ts` added optional chaining around
`app.canvas?.ds?.visible_area` with a `[0, 0]` fallback so inserting an
asset before the canvas finishes initializing no longer crashes. There
was no existing unit test for `litegraphService`, so this regression
could silently return.

## Changes

- **What**: New unit test file `src/services/litegraphService.test.ts`
covering `useLitegraphService().getCanvasCenter`.
- Mocks `@/scripts/app` so `app.canvas` can be swapped per test via
`Reflect.set`.
- Null-canvas case (regression for #8399): returns `[0, 0]` instead of
throwing.
- Missing `ds.visible_area` case: also returns `[0, 0]`.
- Initialised case: returns the centre of the visible area.
- Verified RED→GREEN locally.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11271-test-add-regression-test-for-getCanvasCenter-null-guard-8399-3436d73d3650815c9925c8fdf9ec4bd3)
by [Unito](https://www.unito.io)
2026-04-18 16:32:03 +00:00
Terry Jia
54f3127658 test: regenerate screenshot expectations (#11360)
## Summary
regenerate screenshot expectations

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11360-test-regenerate-screenshot-expectations-3466d73d365081878addd53a266a31b7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-04-18 09:10:02 -04:00
75 changed files with 3779 additions and 780 deletions

View File

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

View File

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

View File

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

View File

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

View 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')
}
)
})

View File

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

View 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')
}
})
})

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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()
})
})

View 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>

View File

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

View File

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

View 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'
)
})
})

View File

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

View File

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

View File

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

View File

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

View 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()
})
})

View 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')
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View 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()
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'],

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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])
})
})

View File

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

View File

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

View File

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

View File

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

View File

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