mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +00:00
Compare commits
13 Commits
feat/color
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2162a93d29 | ||
|
|
e6c04359b5 | ||
|
|
27859cb41c | ||
|
|
3be998aaf4 | ||
|
|
99674df73b | ||
|
|
cfaf89edea | ||
|
|
a154e6a311 | ||
|
|
46fec1d47d | ||
|
|
1052658a02 | ||
|
|
f524683f3c | ||
|
|
d1d55585f9 | ||
|
|
98c654df20 | ||
|
|
5bce9c4874 |
9
.fallowrc.json
Normal file
9
.fallowrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
||||
"entry": ["src/main.ts", "index.html"],
|
||||
"duplicates": {
|
||||
"minOccurrences": 3,
|
||||
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
|
||||
},
|
||||
"rules": {}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ yarn.lock
|
||||
.eslintcache
|
||||
.prettiercache
|
||||
.stylelintcache
|
||||
.fallow/
|
||||
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
@@ -88,6 +88,11 @@ const config: StorybookConfig = {
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/composables/useWorkspaceUI',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
|
||||
@@ -10,11 +10,13 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
|
||||
const LOCALE_PREFIXES = LOCALES.map((locale) =>
|
||||
locale === DEFAULT_LOCALE ? '' : `/${locale}`
|
||||
)
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
|
||||
LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
|
||||
...LOCALE_PREFIXES.flatMap((prefix) =>
|
||||
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
|
||||
)
|
||||
)
|
||||
),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/individual-submission`),
|
||||
...LOCALE_PREFIXES.map((prefix) => `${prefix}/booking-confirmation`)
|
||||
])
|
||||
|
||||
function isExcludedFromSitemap(page: string): boolean {
|
||||
const pathname = new URL(page).pathname.replace(/\/$/, '')
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
|
||||
|
||||
// The setup island hydrates on visibility; clicks before hydration are
|
||||
// no-ops, so retry until the tab actually activates.
|
||||
async function selectClientTab(setup: Locator, name: string) {
|
||||
const tab = setup.getByRole('tab', { name })
|
||||
await expect(async () => {
|
||||
await tab.click()
|
||||
await expect(tab).toHaveAttribute('data-state', 'active', { timeout: 500 })
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
test.describe('MCP page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mcp')
|
||||
@@ -19,46 +30,72 @@ test.describe('MCP page @smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('setup section shows both install options', async ({ page }) => {
|
||||
test('Claude Desktop is the default tab and shows only the connector card', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Desktop' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Add Custom Connector' })
|
||||
).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Install manually' })
|
||||
).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
).toHaveCount(0)
|
||||
await expect(setup.locator('video')).toBeVisible()
|
||||
})
|
||||
|
||||
test('client tabs swap install instructions', async ({ page }) => {
|
||||
test('client tabs swap install instructions and agent-card visibility', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
const agentHeading = setup.getByRole('heading', {
|
||||
name: 'Ask your agent to install Comfy MCP'
|
||||
})
|
||||
|
||||
// Claude Code is the default tab and carries the CLI command
|
||||
await expect(
|
||||
setup.getByRole('tab', { name: 'Claude Code' })
|
||||
).toHaveAttribute('data-state', 'active')
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
// First interaction retries until the island hydrates; later switches
|
||||
// assert synchronously so steady-state click regressions fail.
|
||||
await selectClientTab(setup, 'Claude Code Terminal')
|
||||
await expect(activePanel).toContainText(
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: 'Install manually' })
|
||||
).toBeVisible()
|
||||
await expect(agentHeading).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
)
|
||||
await expect(agentHeading).toHaveCount(0)
|
||||
await expect(setup.locator('video')).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'Cursor' }).click()
|
||||
await expect(activePanel).toContainText('X-API-Key')
|
||||
await expect(
|
||||
activePanel.getByRole('link', { name: 'platform.comfy.org' })
|
||||
).toHaveAttribute('href', 'https://platform.comfy.org/profile/api-keys')
|
||||
await expect(agentHeading).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await setup.getByRole('tab', { name: 'OpenClaw' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
'openclaw skills install @comfy-org/comfy'
|
||||
)
|
||||
await expect(agentHeading).toBeVisible()
|
||||
|
||||
await setup.getByRole('tab', { name: 'Others' }).click()
|
||||
await expect(activePanel).toContainText('remote MCP server')
|
||||
await expect(agentHeading).toBeVisible()
|
||||
})
|
||||
|
||||
test('skills plugin link lives in the agent option card', async ({
|
||||
@@ -66,6 +103,7 @@ test.describe('MCP page @smoke', () => {
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await selectClientTab(setup, 'Claude Code Terminal')
|
||||
await expect(
|
||||
setup.getByRole('link', { name: 'View on GitHub' })
|
||||
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
|
||||
@@ -105,7 +143,10 @@ test.describe('MCP page zh-CN @smoke', () => {
|
||||
await page.goto('/zh-CN/mcp')
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(setup.getByText('方式一')).toBeVisible()
|
||||
await expect(
|
||||
setup.getByRole('heading', { name: '添加自定义连接器' })
|
||||
).toBeVisible()
|
||||
await selectClientTab(setup, 'Claude Code Terminal')
|
||||
await expect(setup.getByRole('heading', { name: '手动安装' })).toBeVisible()
|
||||
await expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
BIN
apps/website/public/videos/mcp/setup-claude-desktop-v1.mp4
Normal file
BIN
apps/website/public/videos/mcp/setup-claude-desktop-v1.mp4
Normal file
Binary file not shown.
BIN
apps/website/public/videos/mcp/setup-codex-oauth-v1.mp4
Normal file
BIN
apps/website/public/videos/mcp/setup-codex-oauth-v1.mp4
Normal file
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
const { resources } = defineProps<{
|
||||
resources: { label: string; href: string; display: string }[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-3 text-base font-light lg:text-lg">
|
||||
<li v-for="resource in resources" :key="resource.href">
|
||||
<span class="text-primary-comfy-canvas">{{ resource.label }}</span>
|
||||
<span class="text-primary-warm-gray"> — </span>
|
||||
<a
|
||||
:href="resource.href"
|
||||
class="text-primary-comfy-yellow underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{{ resource.display }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
minimal = false,
|
||||
hideControls = false,
|
||||
fit = 'cover',
|
||||
ariaLabel,
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
@@ -44,6 +45,7 @@ const {
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
ariaLabel?: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
@@ -93,6 +95,28 @@ watch(videoEl, syncNativeDuration)
|
||||
useEventListener(videoEl, 'loadedmetadata', syncNativeDuration)
|
||||
useEventListener(videoEl, 'durationchange', syncNativeDuration)
|
||||
|
||||
// The muted attribute only sets defaultMuted, so SSR-rendered autoplay
|
||||
// videos count as unmuted and get blocked; force the property and kick
|
||||
// playback. Scoped to hideControls (decorative) clips so chrome-visible
|
||||
// consumers keep native semantics. flush: 'post' guarantees this runs
|
||||
// after useMediaControls' internal muted watcher on the same source.
|
||||
watch(
|
||||
[videoEl, () => src],
|
||||
([el]) => {
|
||||
if (!el || !autoplay || !hideControls) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
el.pause()
|
||||
return
|
||||
}
|
||||
el.muted = true
|
||||
el.play().catch((error: unknown) => {
|
||||
if (error instanceof Error && error.name === 'AbortError') return
|
||||
console.warn('VideoPlayer autoplay failed', error)
|
||||
})
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const effectiveDuration = computed(() => duration.value || nativeDuration.value)
|
||||
|
||||
// Scrubber (modeled after VueUse demo Scrubber.vue)
|
||||
@@ -207,6 +231,7 @@ function toggleFullscreen() {
|
||||
<video
|
||||
v-if="src"
|
||||
ref="videoEl"
|
||||
:aria-label="ariaLabel"
|
||||
:class="
|
||||
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
|
||||
"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import Button from '../ui/button/Button.vue'
|
||||
|
||||
const { href, label } = defineProps<{
|
||||
href: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<Button as="a" :href variant="default" size="lg">
|
||||
{{ label }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,12 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const { title } = defineProps<{ title: string }>()
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { title, class: className } = defineProps<{
|
||||
title: string
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
|
||||
>
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
|
||||
<h1
|
||||
:class="
|
||||
cn(
|
||||
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
@@ -1919,42 +1919,48 @@ const translations = {
|
||||
'zh-CN': '配置 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Two ways to connect: ask your agent to install it, or add the server yourself. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
en: 'Two ways to connect: add the server yourself, or ask your agent to install it. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
'zh-CN':
|
||||
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'两种接入方式:自行添加服务器,或让你的智能体自动安装。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.option1.label': { en: 'OPTION 1', 'zh-CN': '方式一' },
|
||||
'mcp.setup.option1.title': {
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.option1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.option1.description': {
|
||||
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
|
||||
'mcp.setup.option2.title': {
|
||||
'mcp.setup.manual.title': {
|
||||
en: 'Install manually',
|
||||
'zh-CN': '手动安装'
|
||||
},
|
||||
'mcp.setup.option2.description': {
|
||||
en: 'Prefer manual setup? Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
|
||||
'mcp.setup.manual.description': {
|
||||
en: 'Add this URL as a custom connector or remote MCP server in your client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
'将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
},
|
||||
'mcp.setup.option2.tabsLabel': {
|
||||
'mcp.setup.manual.tabsLabel': {
|
||||
en: 'Pick your client',
|
||||
'zh-CN': '选择你的客户端'
|
||||
},
|
||||
'mcp.setup.agent.title': {
|
||||
en: 'Ask your agent to install Comfy MCP',
|
||||
'zh-CN': '让你的智能体安装 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.agent.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.agent.description': {
|
||||
en: 'Prefer to let your agent do it? Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
|
||||
'zh-CN':
|
||||
'想让智能体代劳?将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
|
||||
},
|
||||
'mcp.setup.clients.claudeCode.step': {
|
||||
en: 'Run this in your terminal, then use /mcp to pick comfy-cloud and authenticate.',
|
||||
'zh-CN': '在终端运行以下命令,然后通过 /mcp 选择 comfy-cloud 并完成认证。'
|
||||
},
|
||||
'mcp.setup.walkthroughAlt': {
|
||||
en: '{client} setup walkthrough',
|
||||
'zh-CN': '{client} 设置演示'
|
||||
},
|
||||
'mcp.setup.clients.claudeDesktop.manualTitle': {
|
||||
en: 'Add Custom Connector',
|
||||
'zh-CN': '添加自定义连接器'
|
||||
},
|
||||
'mcp.setup.clients.claudeDesktop.step': {
|
||||
en: 'Click Customize in the sidebar, open Connectors, choose Add custom connector, paste the URL above, and sign in.',
|
||||
'zh-CN':
|
||||
@@ -1973,9 +1979,13 @@ const translations = {
|
||||
en: 'Run this in your terminal, then codex mcp login comfy-cloud to sign in.',
|
||||
'zh-CN': '在终端运行以下命令,然后执行 codex mcp login comfy-cloud 登录。'
|
||||
},
|
||||
'mcp.setup.clients.openclaw.step': {
|
||||
en: 'Run these in your terminal, then openclaw mcp login comfy to sign in.',
|
||||
'zh-CN': '在终端运行以下命令,然后执行 openclaw mcp login comfy 登录。'
|
||||
},
|
||||
'mcp.setup.clients.other.name': {
|
||||
en: 'Other clients',
|
||||
'zh-CN': '其他客户端'
|
||||
en: 'Others',
|
||||
'zh-CN': '其他'
|
||||
},
|
||||
'mcp.setup.clients.other.step': {
|
||||
en: 'Add the URL above as a remote MCP server. No OAuth in your client? Use an X-API-Key header instead. Full walkthroughs live in the ',
|
||||
@@ -2190,9 +2200,9 @@ const translations = {
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'Not for Claude Code, Claude Desktop, or Codex. You need a Comfy API key for Cursor, Hermes, and OpenClaw for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
|
||||
en: 'Not for Claude Code, Claude Desktop, Codex, or OpenClaw. You need a Comfy API key for Cursor and Hermes for now. Just copy https://docs.comfy.org/agent-tools/cloud and your agent will figure out the installation for you.',
|
||||
'zh-CN':
|
||||
'Claude Code、Claude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
'Claude Code、Claude Desktop、Codex 和 OpenClaw 不需要。Cursor 和 Hermes 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: 'Does it cost anything?',
|
||||
|
||||
54
apps/website/src/pages/booking-confirmation.astro
Normal file
54
apps/website/src/pages/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: 'Learning Center',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: 'Workflow templates',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: 'Customer stories',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: 'Docs',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="You're booked - Comfy"
|
||||
description="Your meeting is booked. Check your email for the calendar invite and meeting link."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="You're booked." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>Check your email for the calendar invite and meeting link!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
Resources while you wait
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
60
apps/website/src/pages/individual-submission.astro
Normal file
60
apps/website/src/pages/individual-submission.astro
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../config/routes'
|
||||
|
||||
const routes = getRoutes('en')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Thanks for reaching out - Comfy"
|
||||
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="Thanks for reaching out." class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
Based on what you shared, one of our self-serve plans is probably a
|
||||
better fit.
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>,
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>, and
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
> for individual creators, plus our new
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> plan for multiple users under shared billing.
|
||||
</p>
|
||||
|
||||
<PlansPricingCta
|
||||
href={routes.cloudPricing}
|
||||
label="See plans and pricing →"
|
||||
/>
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
Need help with something else? Email: <a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
54
apps/website/src/pages/zh-CN/booking-confirmation.astro
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ResourceList from '../../components/booking-confirmation/ResourceList.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
|
||||
const resources = [
|
||||
{
|
||||
label: '学习中心',
|
||||
href: routes.learning,
|
||||
display: 'comfy.org/learning'
|
||||
},
|
||||
{
|
||||
label: '工作流模板',
|
||||
href: externalLinks.workflows,
|
||||
display: 'comfy.org/workflows'
|
||||
},
|
||||
{
|
||||
label: '客户案例',
|
||||
href: routes.customers,
|
||||
display: 'comfy.org/customers'
|
||||
},
|
||||
{
|
||||
label: '文档',
|
||||
href: externalLinks.docs,
|
||||
display: 'docs.comfy.org'
|
||||
}
|
||||
]
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="预约成功 - Comfy"
|
||||
description="您的会议已预约成功。请查收邮件中的日历邀请和会议链接。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="预约成功。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-10 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>请查收邮件中的日历邀请和会议链接!</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-primary-comfy-yellow text-xl font-semibold italic lg:text-2xl">
|
||||
等待期间的资源
|
||||
</h2>
|
||||
<ResourceList resources={resources} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
57
apps/website/src/pages/zh-CN/individual-submission.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
import { getRoutes } from '../../config/routes'
|
||||
|
||||
const routes = getRoutes('zh-CN')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="感谢您的联系 - Comfy"
|
||||
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
|
||||
noindex
|
||||
>
|
||||
<HeroSection title="感谢您的联系。" class="text-center" />
|
||||
|
||||
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
|
||||
<div
|
||||
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
|
||||
>
|
||||
<p>
|
||||
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Standard</strong
|
||||
>、
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Creator</strong
|
||||
>
|
||||
和
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Pro</strong
|
||||
>,以及我们全新的
|
||||
<strong class="text-primary-comfy-yellow font-semibold italic">
|
||||
Teams</strong
|
||||
> 套餐,可让多位用户共享账单。
|
||||
</p>
|
||||
|
||||
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
|
||||
|
||||
<p class="text-primary-warm-gray mt-8 text-sm">
|
||||
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
|
||||
href="mailto:gtm-team@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>gtm-team@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
需要其他方面的帮助?邮箱:<a
|
||||
href="mailto:support@comfy.org"
|
||||
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
|
||||
>support@comfy.org</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -78,3 +78,28 @@ describe('captureDownloadClick', () => {
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureMcpClientTabClick', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('captures the tab click with the client id', async () => {
|
||||
const { initPostHog, captureMcpClientTabClick } = await import('./posthog')
|
||||
initPostHog()
|
||||
captureMcpClientTabClick('claude-code')
|
||||
|
||||
expect(hoisted.mockCapture).toHaveBeenCalledWith(
|
||||
'website:mcp_client_tab_clicked',
|
||||
{ client: 'claude-code' }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not capture before PostHog is initialized', async () => {
|
||||
const { captureMcpClientTabClick } = await import('./posthog')
|
||||
captureMcpClientTabClick('cursor')
|
||||
|
||||
expect(hoisted.mockCapture).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,3 +49,12 @@ export function captureDownloadClick(platform: Platform) {
|
||||
console.error('PostHog download click capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function captureMcpClientTabClick(client: string) {
|
||||
if (!initialized) return
|
||||
try {
|
||||
posthog.capture('website:mcp_client_tab_clicked', { client })
|
||||
} catch (error) {
|
||||
console.error('PostHog MCP client tab capture failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import SectionLabel from '../../components/common/SectionLabel.vue'
|
||||
import VideoPlayer from '../../components/common/VideoPlayer.vue'
|
||||
import CopyableField from '../../components/ui/copyable-field/CopyableField.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import { captureMcpClientTabClick } from '../../scripts/posthog'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const agentCommand = t('mcp.setup.option1.command', locale).replace(
|
||||
const agentCommand = t('mcp.setup.agent.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
@@ -21,19 +23,35 @@ interface McpClient {
|
||||
step: string
|
||||
command?: string
|
||||
link?: { label: string; href: string }
|
||||
manualTitle?: string
|
||||
showAgentCard: boolean
|
||||
// Walkthrough clip shown in place of the agent card (source: docs.comfy.org/agent-tools/mcp)
|
||||
video?: string
|
||||
}
|
||||
|
||||
const clients: McpClient[] = [
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
step: t('mcp.setup.clients.claudeCode.step', locale),
|
||||
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'claude-desktop',
|
||||
name: 'Claude Desktop',
|
||||
step: t('mcp.setup.clients.claudeDesktop.step', locale)
|
||||
step: t('mcp.setup.clients.claudeDesktop.step', locale),
|
||||
manualTitle: t('mcp.setup.clients.claudeDesktop.manualTitle', locale),
|
||||
showAgentCard: false,
|
||||
video: '/videos/mcp/setup-claude-desktop-v1.mp4'
|
||||
},
|
||||
{
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code Terminal',
|
||||
step: t('mcp.setup.clients.claudeCode.step', locale),
|
||||
command: `claude mcp add --transport http comfy-cloud ${externalLinks.mcpEndpoint}`,
|
||||
showAgentCard: true
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`,
|
||||
showAgentCard: false,
|
||||
video: '/videos/mcp/setup-codex-oauth-v1.mp4'
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
@@ -42,13 +60,15 @@ const clients: McpClient[] = [
|
||||
link: {
|
||||
label: t('mcp.setup.clients.cursor.linkLabel', locale),
|
||||
href: externalLinks.apiKeys
|
||||
}
|
||||
},
|
||||
showAgentCard: true
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
|
||||
id: 'openclaw',
|
||||
name: 'OpenClaw',
|
||||
step: t('mcp.setup.clients.openclaw.step', locale),
|
||||
command: `openclaw skills install @comfy-org/comfy\nopenclaw mcp set comfy '{"url":"${externalLinks.mcpEndpoint}","transport":"streamable-http","auth":"oauth"}'`,
|
||||
showAgentCard: true
|
||||
},
|
||||
{
|
||||
id: 'other',
|
||||
@@ -57,10 +77,38 @@ const clients: McpClient[] = [
|
||||
link: {
|
||||
label: t('mcp.setup.clients.other.linkLabel', locale),
|
||||
href: externalLinks.docsMcp
|
||||
}
|
||||
},
|
||||
showAgentCard: true
|
||||
}
|
||||
]
|
||||
|
||||
const activeClientId = ref(clients[0].id)
|
||||
const activeClient = computed(
|
||||
() =>
|
||||
clients.find((client) => client.id === activeClientId.value) ?? clients[0]
|
||||
)
|
||||
const manualTitle = computed(
|
||||
() => activeClient.value.manualTitle ?? t('mcp.setup.manual.title', locale)
|
||||
)
|
||||
|
||||
// reka-ui re-emits update:modelValue even when the value is unchanged
|
||||
// (re-clicking the active tab), so dedupe before capturing.
|
||||
let lastTrackedClientId: string | undefined
|
||||
function onClientTabChange(value: string | number | undefined) {
|
||||
if (!value) return
|
||||
const id = String(value)
|
||||
if (id === lastTrackedClientId) return
|
||||
lastTrackedClientId = id
|
||||
captureMcpClientTabClick(id)
|
||||
}
|
||||
|
||||
const walkthroughLabel = computed(() =>
|
||||
t('mcp.setup.walkthroughAlt', locale).replace(
|
||||
'{client}',
|
||||
activeClient.value.name
|
||||
)
|
||||
)
|
||||
|
||||
const copyLabel = t('ui.copy', locale)
|
||||
const copiedLabel = t('ui.copied', locale)
|
||||
</script>
|
||||
@@ -83,77 +131,48 @@ const copiedLabel = t('ui.copied', locale)
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div class="mt-16 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
<TabsRoot
|
||||
v-model="activeClientId"
|
||||
activation-mode="manual"
|
||||
class="mt-10 block"
|
||||
@update:model-value="onClientTabChange"
|
||||
>
|
||||
<TabsList
|
||||
:aria-label="t('mcp.setup.manual.tabsLabel', locale)"
|
||||
class="grid grid-cols-1 gap-px rounded-2xl border border-white/15 bg-primary-comfy-ink p-1 min-[360px]:grid-cols-2 lg:inline-flex lg:flex-nowrap"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
<TabsTrigger
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow shrink-0 cursor-pointer rounded-lg bg-white/8 px-2 py-2.5 text-[10px] font-bold tracking-wider whitespace-nowrap text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink lg:rounded-none lg:px-6 lg:text-xs lg:first:rounded-l-xl lg:last:rounded-r-xl"
|
||||
>
|
||||
{{ t('mcp.setup.option1.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option1.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="agentCommand"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-auto pt-6 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.skillsNote', locale)
|
||||
}}<a
|
||||
:href="externalLinks.mcpSkills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ t('mcp.setup.skillsLink', locale) }}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{{ client.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option2.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
<div class="mt-10 grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
{{ t('mcp.setup.option2.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.option2.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="externalLinks.mcpEndpoint"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsRoot default-value="claude-code" class="mt-6">
|
||||
<TabsList
|
||||
:aria-label="t('mcp.setup.option2.tabsLabel', locale)"
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<TabsTrigger
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="bg-transparency-white-t4 focus-visible:ring-primary-comfy-yellow/50 data-[state=active]:bg-primary-comfy-yellow cursor-pointer rounded-full px-4 py-2 text-xs font-bold tracking-wider text-smoke-700 uppercase transition-colors hover:text-primary-comfy-canvas focus-visible:ring-2 focus-visible:outline-none data-[state=active]:text-primary-comfy-ink"
|
||||
>
|
||||
{{ client.name }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<h3 class="text-xl font-light text-primary-comfy-canvas lg:text-2xl">
|
||||
{{ manualTitle }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.manual.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="externalLinks.mcpEndpoint"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<TabsContent
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="mt-4 flex min-h-24 flex-col gap-3"
|
||||
class="mt-6 flex min-h-36 flex-col gap-3"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ client.step
|
||||
@@ -173,8 +192,57 @@ const copiedLabel = t('ui.copied', locale)
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl"
|
||||
:class="
|
||||
activeClient.showAgentCard
|
||||
? 'p-6 lg:p-8'
|
||||
: 'relative overflow-hidden max-lg:aspect-video'
|
||||
"
|
||||
>
|
||||
<template v-if="activeClient.showAgentCard">
|
||||
<h3
|
||||
class="text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ t('mcp.setup.agent.title', locale) }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.agent.description', locale) }}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<CopyableField
|
||||
:value="agentCommand"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-6 text-sm text-smoke-700">
|
||||
{{ t('mcp.setup.skillsNote', locale)
|
||||
}}<a
|
||||
:href="externalLinks.mcpSkills"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="focus-visible:ring-primary-comfy-yellow/50 rounded-sm text-primary-comfy-canvas underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ t('mcp.setup.skillsLink', locale) }}</a
|
||||
>
|
||||
</p>
|
||||
</template>
|
||||
<VideoPlayer
|
||||
v-else-if="activeClient.video"
|
||||
:key="activeClient.id"
|
||||
:locale="locale"
|
||||
:aria-label="walkthroughLabel"
|
||||
:src="activeClient.video"
|
||||
autoplay
|
||||
loop
|
||||
hide-controls
|
||||
fit="contain"
|
||||
class="absolute inset-0 size-full bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
"source": "/press",
|
||||
"destination": "/about",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/login",
|
||||
"destination": "https://cloud.comfy.org/login",
|
||||
"permanent": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,11 +8,13 @@ export class ComfyActionbar {
|
||||
public readonly root: Locator
|
||||
public readonly queueButton: ComfyQueueButton
|
||||
public readonly propertiesButton: Locator
|
||||
public readonly dragHandle: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.locator('.actionbar-container')
|
||||
this.queueButton = new ComfyQueueButton(this)
|
||||
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
|
||||
this.dragHandle = this.root.locator('.drag-handle')
|
||||
}
|
||||
|
||||
async isDocked() {
|
||||
|
||||
21
browser_tests/fixtures/components/FreeTierQuota.ts
Normal file
21
browser_tests/fixtures/components/FreeTierQuota.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class FreeTierQuota {
|
||||
readonly root: Locator
|
||||
|
||||
constructor(comfyPage: ComfyPage) {
|
||||
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
|
||||
}
|
||||
|
||||
async getMax() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[2]
|
||||
}
|
||||
async getAvailable() {
|
||||
const text = await this.root.textContent()
|
||||
return text?.match(/(\d+) \/ (\d+)/)?.[1]
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,17 @@ class MaskEditorHelper {
|
||||
return dialog
|
||||
}
|
||||
|
||||
async reopenDialog(): Promise<Locator> {
|
||||
const imagePreview = this.page.locator('.image-preview').first()
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await this.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = this.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async drawStrokeOnPointerZone(dialog: Locator) {
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
@@ -103,7 +103,8 @@ export const TestIds = {
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
actionBarButtons: 'action-bar-buttons',
|
||||
freeTierQuota: 'free-tier-quota'
|
||||
},
|
||||
nodeLibrary: {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
|
||||
@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
// consolidated_billing_enabled routes personal workspaces to the unified
|
||||
// pricing table asserted here; without it they fall back to the legacy table.
|
||||
// billing_control_enabled routes personal workspaces to the unified pricing
|
||||
// table asserted here; without it they fall back to the legacy table.
|
||||
const BOOT_FEATURES = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
billing_control_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
|
||||
63
browser_tests/tests/freeTierQuota.spec.ts
Normal file
63
browser_tests/tests/freeTierQuota.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const features = {
|
||||
free_tier_job_allowance_enabled: true,
|
||||
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
|
||||
}
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
})
|
||||
|
||||
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
const freeTierQuota = new FreeTierQuota(comfyPage)
|
||||
|
||||
await test.step('Populates initial state from config', async () => {
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
|
||||
expect(await freeTierQuota.getMax()).toBe('5')
|
||||
})
|
||||
|
||||
await test.step('available decrements on run', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('connects to detached run button', async () => {
|
||||
const handle = comfyPage.actionbar.dragHandle
|
||||
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
|
||||
expect(await freeTierQuota.getAvailable()).toBe('2')
|
||||
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
|
||||
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
|
||||
})
|
||||
|
||||
await test.step('Detects workflows with Partner nodes', async () => {
|
||||
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await node.delete()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
|
||||
})
|
||||
|
||||
await test.step('Does not decrease past 0', async () => {
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
|
||||
await execution.run()
|
||||
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await execution.run()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await freeTierQuota.getAvailable()).toBe(undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -67,6 +67,22 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Reopening the editor after save restores the drawn mask', async ({
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
const savedMask = await maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
await maskEditor.reopenDialog()
|
||||
await expect
|
||||
.poll(() => maskEditor.getCanvasSnapshot(MASK_CANVAS_INDEX))
|
||||
.toBe(savedMask)
|
||||
})
|
||||
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
|
||||
"dev": "vite --config vite.config.mts",
|
||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||
"fallow": "fallow",
|
||||
"fallow:audit": "fallow audit",
|
||||
"format:check": "oxfmt --check",
|
||||
"format": "oxfmt --write",
|
||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||
@@ -172,6 +174,7 @@
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fallow": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"globals": "catalog:",
|
||||
|
||||
@@ -40,6 +40,11 @@ export type ComfyDesktop2TelemetryProperties = Record<
|
||||
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
|
||||
>
|
||||
|
||||
export type ComfyDesktop2FirebaseAuthState =
|
||||
| { status: 'pending' }
|
||||
| { status: 'signed_out' }
|
||||
| { status: 'signed_in'; userId: string }
|
||||
|
||||
export interface ComfyDesktop2TerminalBridge {
|
||||
subscribe(installationId?: string): Promise<TerminalRestore>
|
||||
unsubscribe(installationId?: string): Promise<void>
|
||||
@@ -60,6 +65,7 @@ export interface ComfyDesktop2LogsBridge {
|
||||
|
||||
export interface ComfyDesktop2TelemetryBridge {
|
||||
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
|
||||
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
|
||||
}
|
||||
|
||||
export interface ComfyDesktop2Bridge {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-desktop-bridge-types",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
|
||||
"homepage": "https://comfy.org",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendWorkflowJsonExt,
|
||||
ensureWorkflowSuffix,
|
||||
escapeVueI18nMessageSyntax,
|
||||
formatLocalizedMediumDate,
|
||||
formatLocalizedNumber,
|
||||
getFilePathSeparatorVariants,
|
||||
@@ -478,49 +477,4 @@ describe('formatUtil', () => {
|
||||
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeVueI18nMessageSyntax', () => {
|
||||
it('escapes a literal @ that would break linked-message compilation', () => {
|
||||
expect(
|
||||
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
|
||||
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
|
||||
})
|
||||
|
||||
it('escapes @ in an email address', () => {
|
||||
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
|
||||
"support{'@'}comfy.org"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes interpolation braces', () => {
|
||||
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
|
||||
"size {'{'}w{'}'}x{'{'}h{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the plural pipe separator', () => {
|
||||
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
|
||||
"foreground {'|'} background"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes the modulo percent so it cannot re-form %{', () => {
|
||||
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
|
||||
"50{'%'}{'{'}done{'}'}"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes every occurrence in a single pass', () => {
|
||||
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
|
||||
"{'@'}a {'@'}b {'@'}c"
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves strings without syntax characters unchanged', () => {
|
||||
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
|
||||
'no special chars here'
|
||||
)
|
||||
expect(escapeVueI18nMessageSyntax('')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,40 +178,6 @@ export function normalizeI18nKey(key: string) {
|
||||
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Characters that vue-i18n's message compiler treats as syntax in message text,
|
||||
* so plain text has to escape them to render verbatim through `t()`/`st()`:
|
||||
*
|
||||
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
|
||||
* `Invalid linked format`.
|
||||
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
|
||||
* brace throws `Unterminated/Unbalanced closing brace`.
|
||||
* - `|` separates plural branches, so `a | b` silently renders as one branch.
|
||||
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
|
||||
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
|
||||
*
|
||||
* The set is a build-inlined `const enum` (`TokenChars`) in
|
||||
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
|
||||
*
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
|
||||
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
|
||||
*/
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
|
||||
|
||||
/**
|
||||
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
|
||||
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
|
||||
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
|
||||
*
|
||||
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
|
||||
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
|
||||
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
|
||||
* escape output itself contains `{`/`}`, so it is not idempotent.
|
||||
*/
|
||||
export function escapeVueI18nMessageSyntax(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
|
||||
* @param input The dynamic prompt to process
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -240,6 +240,9 @@ catalogs:
|
||||
eslint-plugin-vue:
|
||||
specifier: ^10.9.1
|
||||
version: 10.9.1
|
||||
fallow:
|
||||
specifier: ^2.102.0
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: ^4.5.3
|
||||
version: 4.5.3
|
||||
@@ -763,6 +766,9 @@ importers:
|
||||
eslint-plugin-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
|
||||
fallow:
|
||||
specifier: 'catalog:'
|
||||
version: 2.102.0
|
||||
fast-check:
|
||||
specifier: 'catalog:'
|
||||
version: 4.5.3
|
||||
@@ -1908,6 +1914,46 @@ packages:
|
||||
'@exodus/crypto':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@firebase/analytics-compat@0.2.18':
|
||||
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
|
||||
peerDependencies:
|
||||
@@ -5682,6 +5728,11 @@ packages:
|
||||
extendable-media-recorder@9.2.27:
|
||||
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
|
||||
|
||||
fallow@2.102.0:
|
||||
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
fast-check@4.5.3:
|
||||
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
|
||||
engines: {node: '>=12.17.0'}
|
||||
@@ -10044,6 +10095,30 @@ snapshots:
|
||||
|
||||
'@exodus/bytes@1.7.0': {}
|
||||
|
||||
'@fallow-cli/darwin-arm64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/darwin-x64@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-arm64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-gnu@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/linux-x64-musl@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-arm64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@fallow-cli/win32-x64-msvc@2.102.0':
|
||||
optional: true
|
||||
|
||||
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
|
||||
dependencies:
|
||||
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
|
||||
@@ -14095,6 +14170,19 @@ snapshots:
|
||||
subscribable-things: 2.1.53
|
||||
tslib: 2.8.1
|
||||
|
||||
fallow@2.102.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@fallow-cli/darwin-arm64': 2.102.0
|
||||
'@fallow-cli/darwin-x64': 2.102.0
|
||||
'@fallow-cli/linux-arm64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-arm64-musl': 2.102.0
|
||||
'@fallow-cli/linux-x64-gnu': 2.102.0
|
||||
'@fallow-cli/linux-x64-musl': 2.102.0
|
||||
'@fallow-cli/win32-arm64-msvc': 2.102.0
|
||||
'@fallow-cli/win32-x64-msvc': 2.102.0
|
||||
|
||||
fast-check@4.5.3:
|
||||
dependencies:
|
||||
pure-rand: 7.0.1
|
||||
|
||||
@@ -89,6 +89,7 @@ catalog:
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
fallow: ^2.102.0
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
|
||||
@@ -3,11 +3,9 @@ import * as fs from 'fs'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
|
||||
import {
|
||||
escapeVueI18nMessageSyntax,
|
||||
normalizeI18nKey
|
||||
} from '@/utils/formatUtil'
|
||||
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
|
||||
import type { WidgetLabels } from './nodeDefLocaleSerializer'
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
@@ -17,10 +15,6 @@ interface WidgetInfo {
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
@@ -47,26 +41,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
)
|
||||
|
||||
const allDataTypesLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => {
|
||||
const inputDataTypes = Object.values(nodeDef.inputs).map(
|
||||
(inputSpec) => inputSpec.type
|
||||
)
|
||||
const outputDataTypes = nodeDef.outputs.map(
|
||||
(outputSpec) => outputSpec.type
|
||||
)
|
||||
const allDataTypes = [...inputDataTypes, ...outputDataTypes].flatMap(
|
||||
(type: string) => type.split(',')
|
||||
)
|
||||
return allDataTypes.map((dataType) => [
|
||||
normalizeI18nKey(dataType),
|
||||
escapeVueI18nMessageSyntax(dataType)
|
||||
])
|
||||
})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
|
||||
@@ -95,14 +69,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
)
|
||||
|
||||
// Format runtime widgets
|
||||
const runtimeWidgets = Object.fromEntries(
|
||||
Object.entries(widgetsMappings)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([key, value]) => [
|
||||
normalizeI18nKey(key),
|
||||
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
|
||||
])
|
||||
.map(([key, name]) => [key, { name }])
|
||||
)
|
||||
|
||||
if (Object.keys(runtimeWidgets).length > 0) {
|
||||
@@ -121,97 +91,8 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
}
|
||||
|
||||
const nodeDefLabels = await extractWidgetLabels()
|
||||
|
||||
function extractInputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap((input) => {
|
||||
const name =
|
||||
input.name === undefined
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(input.name)
|
||||
const tooltip = input.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(input.name),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function extractOutputs(nodeDef: ComfyNodeDefImpl) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap((output, i) => {
|
||||
// Ignore data types if they are already translated in allDataTypesLocale.
|
||||
const name =
|
||||
output.name === undefined || output.name in allDataTypesLocale
|
||||
? undefined
|
||||
: escapeVueI18nMessageSyntax(output.name)
|
||||
const tooltip = output.tooltip
|
||||
|
||||
if (name === undefined && tooltip === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
i.toString(),
|
||||
{
|
||||
name,
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
const allNodeDefsLocale = Object.fromEntries(
|
||||
nodeDefs
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...extractInputs(nodeDef),
|
||||
...(nodeDefLabels[nodeDef.name] ?? {})
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeVueI18nMessageSyntax(
|
||||
nodeDef.display_name ?? nodeDef.name
|
||||
),
|
||||
description: nodeDef.description
|
||||
? escapeVueI18nMessageSyntax(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: extractOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const allNodeCategoriesLocale = Object.fromEntries(
|
||||
nodeDefs.flatMap((nodeDef) =>
|
||||
nodeDef.category
|
||||
.split('/')
|
||||
.map((category) => [
|
||||
normalizeI18nKey(category),
|
||||
escapeVueI18nMessageSyntax(category)
|
||||
])
|
||||
)
|
||||
)
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(nodeDefs, nodeDefLabels)
|
||||
|
||||
const locale = JSON.parse(fs.readFileSync(localePath, 'utf-8'))
|
||||
fs.writeFileSync(
|
||||
@@ -219,13 +100,13 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
JSON.stringify(
|
||||
{
|
||||
...locale,
|
||||
dataTypes: allDataTypesLocale,
|
||||
nodeCategories: allNodeCategoriesLocale
|
||||
dataTypes,
|
||||
nodeCategories
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(allNodeDefsLocale, null, 2))
|
||||
fs.writeFileSync(nodeDefsPath, JSON.stringify(nodeDefinitions, null, 2))
|
||||
})
|
||||
|
||||
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
130
scripts/nodeDefLocaleSerializer.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { serializeNodeDefLocales } from './nodeDefLocaleSerializer'
|
||||
|
||||
function render(message: string): string {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { value: message } }
|
||||
})
|
||||
return i18n.global.t('value')
|
||||
}
|
||||
|
||||
describe('serializeNodeDefLocales', () => {
|
||||
it('escapes compiled fields and preserves raw tooltips', () => {
|
||||
const syntax = '@ $ {value} | 50%{done}'
|
||||
const inputName = `Input ${syntax}`
|
||||
const outputName = `Output ${syntax}`
|
||||
const dataType = `TYPE ${syntax}`
|
||||
const category = `Category ${syntax}`
|
||||
const nodeDef = {
|
||||
name: 'Test.Node',
|
||||
display_name: `Display ${syntax}`,
|
||||
description: `Description ${syntax}`,
|
||||
category,
|
||||
inputs: {
|
||||
input: {
|
||||
name: inputName,
|
||||
type: dataType,
|
||||
tooltip: `Input tooltip ${syntax}`
|
||||
}
|
||||
},
|
||||
outputs: [
|
||||
{
|
||||
name: outputName,
|
||||
type: 'OTHER',
|
||||
tooltip: `Output tooltip ${syntax}`
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales([nodeDef], {
|
||||
'Test.Node': {
|
||||
'Runtime.Widget': { name: `Widget ${syntax}` }
|
||||
}
|
||||
})
|
||||
const serializedNode = nodeDefinitions.Test_Node
|
||||
const serializedInput =
|
||||
serializedNode.inputs['Input @ $ {value} | 50%{done}']
|
||||
const serializedOutput = serializedNode.outputs['0']
|
||||
|
||||
expect(render(serializedNode.display_name)).toBe(nodeDef.display_name)
|
||||
expect(render(serializedNode.description)).toBe(nodeDef.description)
|
||||
expect(render(serializedInput.name)).toBe(inputName)
|
||||
expect(render(serializedOutput.name)).toBe(outputName)
|
||||
expect(render(serializedNode.inputs.Runtime_Widget.name)).toBe(
|
||||
`Widget ${syntax}`
|
||||
)
|
||||
expect(render(dataTypes[dataType])).toBe(dataType)
|
||||
expect(render(nodeCategories[category])).toBe(category)
|
||||
expect(serializedInput.tooltip).toBe(nodeDef.inputs.input.tooltip)
|
||||
expect(serializedOutput.tooltip).toBe(nodeDef.outputs[0].tooltip)
|
||||
})
|
||||
|
||||
it('preserves locale shapes and ordering', () => {
|
||||
const { dataTypes, nodeCategories, nodeDefinitions } =
|
||||
serializeNodeDefLocales(
|
||||
[
|
||||
{
|
||||
name: 'Z.Node',
|
||||
description: '',
|
||||
category: 'group/sub.group',
|
||||
inputs: {
|
||||
omitted: { type: 'Z.TYPE' },
|
||||
tooltipOnly: { type: 'A_TYPE', tooltip: 'raw @ tooltip' }
|
||||
},
|
||||
outputs: [
|
||||
{ name: 'A_TYPE', type: 'A_TYPE' },
|
||||
{ name: 'Custom.Output', type: 'Z.TYPE' },
|
||||
{ tooltip: 'raw output @ tooltip', type: 'Z.TYPE' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'A.Node',
|
||||
category: 'group',
|
||||
inputs: {},
|
||||
outputs: []
|
||||
}
|
||||
],
|
||||
{
|
||||
'Z.Node': {
|
||||
'Runtime.Widget': { name: 'Runtime.Label' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(dataTypes).toEqual({
|
||||
A_TYPE: 'A_TYPE',
|
||||
Z_TYPE: 'Z.TYPE'
|
||||
})
|
||||
expect(nodeCategories).toEqual({
|
||||
group: 'group',
|
||||
sub_group: 'sub.group'
|
||||
})
|
||||
expect(nodeDefinitions).toEqual({
|
||||
A_Node: {
|
||||
display_name: 'A.Node',
|
||||
description: undefined,
|
||||
inputs: undefined,
|
||||
outputs: undefined
|
||||
},
|
||||
Z_Node: {
|
||||
display_name: 'Z.Node',
|
||||
description: undefined,
|
||||
inputs: {
|
||||
'': { name: undefined, tooltip: 'raw @ tooltip' },
|
||||
Runtime_Widget: { name: 'Runtime.Label' }
|
||||
},
|
||||
outputs: {
|
||||
1: { name: 'Custom.Output', tooltip: undefined },
|
||||
2: { name: undefined, tooltip: 'raw output @ tooltip' }
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(Object.keys(dataTypes)).toEqual(['A_TYPE', 'Z_TYPE'])
|
||||
expect(Object.keys(nodeDefinitions)).toEqual(['A_Node', 'Z_Node'])
|
||||
})
|
||||
})
|
||||
127
scripts/nodeDefLocaleSerializer.ts
Normal file
127
scripts/nodeDefLocaleSerializer.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
interface LocalizableInput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableOutput {
|
||||
type: string
|
||||
name?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface LocalizableNodeDef {
|
||||
category: string
|
||||
inputs: Record<string, LocalizableInput>
|
||||
name: string
|
||||
outputs: LocalizableOutput[]
|
||||
description?: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export type WidgetLabels = Record<
|
||||
string,
|
||||
Record<string, { name: string | undefined }>
|
||||
>
|
||||
|
||||
const VUE_I18N_SYNTAX_CHARS = /[@${}|%]/g
|
||||
|
||||
function escapeMessage(text: string): string {
|
||||
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
|
||||
}
|
||||
|
||||
export function serializeNodeDefLocales(
|
||||
nodeDefs: readonly LocalizableNodeDef[],
|
||||
widgetLabels: WidgetLabels = {}
|
||||
) {
|
||||
const dataTypes = Object.fromEntries(
|
||||
nodeDefs
|
||||
.flatMap((nodeDef) => [
|
||||
...Object.values(nodeDef.inputs).map(({ type }) => type),
|
||||
...nodeDef.outputs.map(({ type }) => type)
|
||||
])
|
||||
.flatMap((type) => type.split(','))
|
||||
.map((dataType) => [normalizeI18nKey(dataType), escapeMessage(dataType)])
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
)
|
||||
|
||||
function serializeInputs(nodeDef: LocalizableNodeDef) {
|
||||
const inputs = Object.fromEntries(
|
||||
Object.values(nodeDef.inputs).flatMap(({ name, tooltip }) => {
|
||||
if (name === undefined && tooltip === undefined) return []
|
||||
|
||||
return [
|
||||
[
|
||||
normalizeI18nKey(name ?? ''),
|
||||
{
|
||||
name: name === undefined ? undefined : escapeMessage(name),
|
||||
tooltip
|
||||
}
|
||||
]
|
||||
]
|
||||
})
|
||||
)
|
||||
return Object.keys(inputs).length > 0 ? inputs : undefined
|
||||
}
|
||||
|
||||
function serializeOutputs(nodeDef: LocalizableNodeDef) {
|
||||
const outputs = Object.fromEntries(
|
||||
nodeDef.outputs.flatMap(({ name, tooltip }, index) => {
|
||||
const serializedName =
|
||||
name === undefined || name in dataTypes
|
||||
? undefined
|
||||
: escapeMessage(name)
|
||||
if (serializedName === undefined && tooltip === undefined) return []
|
||||
|
||||
return [[index.toString(), { name: serializedName, tooltip }]]
|
||||
})
|
||||
)
|
||||
return Object.keys(outputs).length > 0 ? outputs : undefined
|
||||
}
|
||||
|
||||
function serializeWidgetLabels(nodeName: string) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(widgetLabels[nodeName] ?? {}).map(([name, label]) => [
|
||||
normalizeI18nKey(name),
|
||||
{
|
||||
name: label.name === undefined ? undefined : escapeMessage(label.name)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
const nodeDefinitions = Object.fromEntries(
|
||||
[...nodeDefs]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((nodeDef) => {
|
||||
const inputs = {
|
||||
...serializeInputs(nodeDef),
|
||||
...serializeWidgetLabels(nodeDef.name)
|
||||
}
|
||||
|
||||
return [
|
||||
normalizeI18nKey(nodeDef.name),
|
||||
{
|
||||
display_name: escapeMessage(nodeDef.display_name ?? nodeDef.name),
|
||||
description: nodeDef.description
|
||||
? escapeMessage(nodeDef.description)
|
||||
: undefined,
|
||||
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
|
||||
outputs: serializeOutputs(nodeDef)
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const nodeCategories = Object.fromEntries(
|
||||
nodeDefs.flatMap(({ category }) =>
|
||||
category
|
||||
.split('/')
|
||||
.map((part) => [normalizeI18nKey(part), escapeMessage(part)])
|
||||
)
|
||||
)
|
||||
|
||||
return { dataTypes, nodeCategories, nodeDefinitions }
|
||||
}
|
||||
@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer!.classList).toContain('px-2')
|
||||
expect(actionbarContainer!.classList).not.toContain('w-0')
|
||||
} finally {
|
||||
unmount()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mx-1 flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<div
|
||||
v-if="managerState.shouldShowManagerButtons.value || isCloud"
|
||||
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
|
||||
@@ -34,61 +34,75 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
|
||||
>
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container relative flex items-center gap-2',
|
||||
isActionbarContainerEmpty &&
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
ref="legacyCommandsContainerRef"
|
||||
data-testid="legacy-topbar-container"
|
||||
class="[&:not(:has(*>*:not(:empty)))]:hidden"
|
||||
></div>
|
||||
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.bottom="shareTooltipConfig"
|
||||
variant="secondary"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[comfy--send] size-4" />
|
||||
<span class="not-md:hidden">
|
||||
{{ t('actionbar.share') }}
|
||||
</span>
|
||||
</Button>
|
||||
<div v-if="!isRightSidePanelOpen" class="relative">
|
||||
<Button
|
||||
v-tooltip.bottom="rightSidePanelTooltipConfig"
|
||||
:class="
|
||||
cn(
|
||||
showErrorIndicatorOnPanelButton &&
|
||||
'outline-1 outline-destructive-background'
|
||||
)
|
||||
"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('rightSidePanel.togglePanel')"
|
||||
@click="openRightSidePanel"
|
||||
>
|
||||
<i class="icon-[lucide--panel-right] size-4" />
|
||||
</Button>
|
||||
<StatusBadge
|
||||
v-if="showErrorIndicatorOnPanelButton"
|
||||
variant="dot"
|
||||
severity="danger"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isActionbarFloating" />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorOverlay />
|
||||
@@ -147,6 +161,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -209,21 +224,6 @@ const hasDockedButtons = computed(() => {
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
return cn(base, 'px-2', 'border-interface-stroke')
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
</Button>
|
||||
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||
</div>
|
||||
<FreeTierQuota v-if="!isDocked" />
|
||||
</Panel>
|
||||
|
||||
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
|
||||
@@ -109,6 +110,7 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
|
||||
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
|
||||
|
||||
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
|
||||
|
||||
const mockIsActiveSubscription = ref(true)
|
||||
const mockCanRunWorkflows = ref(true)
|
||||
|
||||
vi.mock('@/composables/billing/useBillingContext', () => ({
|
||||
useBillingContext: () => ({
|
||||
isActiveSubscription: mockIsActiveSubscription
|
||||
canRunWorkflows: mockCanRunWorkflows
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -32,7 +32,7 @@ function renderWrapper() {
|
||||
|
||||
describe('CloudRunButtonWrapper', () => {
|
||||
beforeEach(() => {
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanRunWorkflows.value = true
|
||||
})
|
||||
|
||||
it('renders the runnable queue button when the subscription is active', () => {
|
||||
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('locks the run button when the subscription is inactive', () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanRunWorkflows.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
|
||||
})
|
||||
|
||||
it('unlocks the run button once the subscription becomes active again', async () => {
|
||||
mockIsActiveSubscription.value = false
|
||||
mockCanRunWorkflows.value = false
|
||||
renderWrapper()
|
||||
|
||||
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
|
||||
|
||||
mockIsActiveSubscription.value = true
|
||||
mockCanRunWorkflows.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByTestId('queue-button')).toBeInTheDocument()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<component
|
||||
:is="currentButton"
|
||||
:key="isActiveSubscription ? 'queue' : 'subscribe'"
|
||||
:key="canRunWorkflows ? 'queue' : 'subscribe'"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
|
||||
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const { canRunWorkflows } = useBillingContext()
|
||||
|
||||
const currentButton = computed(() =>
|
||||
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
|
||||
)
|
||||
</script>
|
||||
|
||||
160
src/components/cameraInfo/CameraInfo.test.ts
Normal file
160
src/components/cameraInfo/CameraInfo.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
showGizmos: 'Show gizmos',
|
||||
hideGizmos: 'Hide gizmos',
|
||||
lookThrough: 'Camera view',
|
||||
exitLookThrough: 'Exit camera view',
|
||||
transformGizmo: {
|
||||
none: 'None',
|
||||
target: 'Target',
|
||||
cameraTranslate: 'Cam pos',
|
||||
cameraRotate: 'Cam rot'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type ApiMocks = Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
const holder = vi.hoisted(() => ({
|
||||
modeRef: null as { value: string } | null,
|
||||
api: null as ApiMocks | null
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useCameraInfo', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const modeRef = ref('orbit')
|
||||
const api = {
|
||||
initialize: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
handleMouseEnter: vi.fn(),
|
||||
handleMouseLeave: vi.fn(),
|
||||
setGizmosVisible: vi.fn(),
|
||||
setTransformGizmoMode: vi.fn(),
|
||||
setLookThrough: vi.fn()
|
||||
}
|
||||
holder.modeRef = modeRef
|
||||
holder.api = api
|
||||
return { useCameraInfo: () => ({ ...api, mode: modeRef }) }
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
...actual,
|
||||
useElementSize: () => ({ width: ref(600), height: ref(400) })
|
||||
}
|
||||
})
|
||||
|
||||
import CameraInfo from './CameraInfo.vue'
|
||||
|
||||
function makeWidget(): SimplifiedWidget {
|
||||
return {
|
||||
name: 'camera_info_state',
|
||||
type: 'cameraInfo',
|
||||
value: [],
|
||||
options: {}
|
||||
} as unknown as SimplifiedWidget
|
||||
}
|
||||
|
||||
function renderComponent() {
|
||||
return render(CameraInfo, {
|
||||
props: { widget: makeWidget() },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: {} }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setMode(value: string) {
|
||||
holder.modeRef!.value = value
|
||||
}
|
||||
|
||||
function api(): ApiMocks {
|
||||
return holder.api!
|
||||
}
|
||||
|
||||
describe('CameraInfo toolbar', () => {
|
||||
beforeEach(() => {
|
||||
setMode('orbit')
|
||||
Object.values(api()).forEach((fn) => fn.mockClear())
|
||||
})
|
||||
|
||||
it('initializes the viewport on mount and cleans up on unmount', () => {
|
||||
const { unmount } = renderComponent()
|
||||
expect(api().initialize).toHaveBeenCalledOnce()
|
||||
|
||||
unmount()
|
||||
expect(api().cleanup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('toggles gizmo visibility when the gizmos button is clicked', async () => {
|
||||
renderComponent()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Hide gizmos' }))
|
||||
|
||||
expect(api().setGizmosVisible).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('enters camera view when the camera-view button is clicked', async () => {
|
||||
renderComponent()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Camera view' }))
|
||||
|
||||
expect(api().setLookThrough).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraInfo transform gizmo reconciliation', () => {
|
||||
beforeEach(() => {
|
||||
setMode('orbit')
|
||||
Object.values(api()).forEach((fn) => fn.mockClear())
|
||||
})
|
||||
|
||||
it('resets the selected gizmo to none when the new mode disables it', async () => {
|
||||
setMode('quaternion')
|
||||
renderComponent()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Cam rot' }))
|
||||
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith(
|
||||
'camera-rotate'
|
||||
)
|
||||
|
||||
setMode('orbit')
|
||||
await nextTick()
|
||||
|
||||
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith('none')
|
||||
})
|
||||
|
||||
it('keeps the selected gizmo when the new mode still supports it', async () => {
|
||||
setMode('orbit')
|
||||
renderComponent()
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Target' }))
|
||||
expect(api().setTransformGizmoMode).toHaveBeenLastCalledWith('target')
|
||||
|
||||
setMode('look_at')
|
||||
await nextTick()
|
||||
|
||||
expect(api().setTransformGizmoMode).not.toHaveBeenCalledWith('none')
|
||||
})
|
||||
})
|
||||
225
src/components/cameraInfo/CameraInfo.vue
Normal file
225
src/components/cameraInfo/CameraInfo.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full min-h-[300px]"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<div
|
||||
ref="container"
|
||||
class="relative size-full"
|
||||
data-capture-wheel="true"
|
||||
tabindex="-1"
|
||||
@pointerdown.stop="focusContainer"
|
||||
@contextmenu.stop.prevent
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0">
|
||||
<div
|
||||
ref="toolbar"
|
||||
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<button
|
||||
v-tooltip.bottom="tip(gizmosLabel)"
|
||||
type="button"
|
||||
:disabled="lookingThrough"
|
||||
:class="
|
||||
cn(
|
||||
actionClass(!lookingThrough && gizmosOn),
|
||||
lookingThrough && 'cursor-not-allowed opacity-40'
|
||||
)
|
||||
"
|
||||
:aria-pressed="!lookingThrough && gizmosOn"
|
||||
:aria-label="compact ? gizmosLabel : undefined"
|
||||
@click="toggleGizmos"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
gizmosOn ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span v-if="!compact">{{ gizmosLabel }}</span>
|
||||
</button>
|
||||
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
|
||||
<button
|
||||
v-for="option in transformGizmoOptions"
|
||||
:key="option.value"
|
||||
v-tooltip.bottom="tip($t(option.labelKey))"
|
||||
type="button"
|
||||
:disabled="lookingThrough || !option.enabled"
|
||||
:aria-pressed="!lookingThrough && transformGizmoMode === option.value"
|
||||
:aria-label="compact ? $t(option.labelKey) : undefined"
|
||||
:class="
|
||||
cn(
|
||||
actionClass(
|
||||
!lookingThrough && transformGizmoMode === option.value
|
||||
),
|
||||
(lookingThrough || !option.enabled) &&
|
||||
'cursor-not-allowed opacity-40'
|
||||
)
|
||||
"
|
||||
@click="selectTransformGizmo(option.value)"
|
||||
>
|
||||
<i :class="cn('size-4', option.icon)" />
|
||||
<span v-if="!compact">{{ $t(option.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0">
|
||||
<div
|
||||
class="pointer-events-auto flex h-10 items-center justify-end gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<button
|
||||
v-tooltip.top="tip(lookThroughLabel)"
|
||||
type="button"
|
||||
:class="
|
||||
cn(iconBtnClass, lookingThrough && 'bg-button-active-surface')
|
||||
"
|
||||
:aria-pressed="lookingThrough"
|
||||
:aria-label="lookThroughLabel"
|
||||
@click="toggleLookThrough"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
iconBtnClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import { useCameraInfo } from '@/composables/useCameraInfo'
|
||||
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
|
||||
function isComponentWidget(
|
||||
w: ComponentWidget<string[]> | SimplifiedWidget
|
||||
): w is ComponentWidget<string[]> {
|
||||
return 'node' in w && w.node !== undefined
|
||||
}
|
||||
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
if (isComponentWidget(widget)) {
|
||||
node.value = widget.node
|
||||
} else if (nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = resolveNode(nodeId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const toolbar = ref<HTMLElement | null>(null)
|
||||
const { width: toolbarWidth } = useElementSize(toolbar)
|
||||
const compactWidthThreshold = 480
|
||||
const compact = computed(
|
||||
() => toolbarWidth.value > 0 && toolbarWidth.value < compactWidthThreshold
|
||||
)
|
||||
const gizmosOn = ref(true)
|
||||
const lookingThrough = ref(false)
|
||||
const transformGizmoMode = ref<TransformGizmoMode>('none')
|
||||
const {
|
||||
initialize,
|
||||
cleanup,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setGizmosVisible,
|
||||
setTransformGizmoMode,
|
||||
setLookThrough,
|
||||
mode
|
||||
} = useCameraInfo(node as Ref<LGraphNode | null>)
|
||||
|
||||
const gizmosLabel = computed(() =>
|
||||
gizmosOn.value ? t('load3d.hideGizmos') : t('load3d.showGizmos')
|
||||
)
|
||||
|
||||
const lookThroughLabel = computed(() =>
|
||||
lookingThrough.value ? t('load3d.exitLookThrough') : t('load3d.lookThrough')
|
||||
)
|
||||
|
||||
const transformGizmoOptions = computed(() => [
|
||||
{
|
||||
value: 'none' as const,
|
||||
labelKey: 'load3d.transformGizmo.none',
|
||||
icon: 'icon-[lucide--ban]',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
value: 'target' as const,
|
||||
labelKey: 'load3d.transformGizmo.target',
|
||||
icon: 'icon-[lucide--target]',
|
||||
enabled: mode.value === 'orbit' || mode.value === 'look_at'
|
||||
},
|
||||
{
|
||||
value: 'camera-translate' as const,
|
||||
labelKey: 'load3d.transformGizmo.cameraTranslate',
|
||||
icon: 'icon-[lucide--move-3d]',
|
||||
enabled: mode.value === 'look_at' || mode.value === 'quaternion'
|
||||
},
|
||||
{
|
||||
value: 'camera-rotate' as const,
|
||||
labelKey: 'load3d.transformGizmo.cameraRotate',
|
||||
icon: 'icon-[lucide--rotate-3d]',
|
||||
enabled: mode.value === 'quaternion'
|
||||
}
|
||||
])
|
||||
|
||||
function focusContainer() {
|
||||
container.value?.focus()
|
||||
}
|
||||
|
||||
function toggleGizmos() {
|
||||
gizmosOn.value = !gizmosOn.value
|
||||
}
|
||||
|
||||
function toggleLookThrough() {
|
||||
lookingThrough.value = !lookingThrough.value
|
||||
}
|
||||
|
||||
function selectTransformGizmo(value: TransformGizmoMode) {
|
||||
transformGizmoMode.value = value
|
||||
}
|
||||
|
||||
watch(gizmosOn, (on) => setGizmosVisible(on))
|
||||
watch(transformGizmoMode, (m) => setTransformGizmoMode(m))
|
||||
watch(lookingThrough, (on) => setLookThrough(on))
|
||||
watch(mode, () => {
|
||||
const selected = transformGizmoOptions.value.find(
|
||||
({ value }) => value === transformGizmoMode.value
|
||||
)
|
||||
if (!selected?.enabled) transformGizmoMode.value = 'none'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) initialize(container.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
@@ -72,6 +72,8 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:use-custom-up="cameraConfig!.useCustomUp"
|
||||
:has-custom-up="cameraConfig!.hasCustomUp ?? false"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -13,6 +13,33 @@
|
||||
>
|
||||
<i class="pi pi-camera text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasCustomUp"
|
||||
v-tooltip.right="{
|
||||
value: useCustomUp
|
||||
? $t('load3d.useNaturalUp')
|
||||
: $t('load3d.useCustomUp'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="
|
||||
useCustomUp ? $t('load3d.useNaturalUp') : $t('load3d.useCustomUp')
|
||||
"
|
||||
@click="toggleUp"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
useCustomUp
|
||||
? 'icon-[lucide--compass]'
|
||||
: 'icon-[lucide--rotate-ccw]',
|
||||
'size-5 text-base-foreground'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
v-model="fov"
|
||||
@@ -27,13 +54,24 @@ import { computed } from 'vue'
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { hasCustomUp = false } = defineProps<{
|
||||
hasCustomUp?: boolean
|
||||
}>()
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const useCustomUp = defineModel<boolean>('useCustomUp', { default: false })
|
||||
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
const toggleUp = () => {
|
||||
useCustomUp.value = !useCustomUp.value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPickerPanel from '@/components/ui/color-picker/ColorPickerPanel.vue'
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
color: {
|
||||
hex: 'Hex',
|
||||
rgba: 'RGBA',
|
||||
hue: 'Hue',
|
||||
alpha: 'Alpha',
|
||||
red: 'Red',
|
||||
green: 'Green',
|
||||
blue: 'Blue',
|
||||
saturationBrightness: 'Saturation and brightness'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function renderPanel(initial: HSVA, mode: 'hex' | 'rgba' = 'hex') {
|
||||
const hsva = ref<HSVA>(initial)
|
||||
const displayMode = ref<'hex' | 'rgba'>(mode)
|
||||
|
||||
const Host = defineComponent(
|
||||
() => () =>
|
||||
h(ColorPickerPanel, {
|
||||
hsva: hsva.value,
|
||||
'onUpdate:hsva': (value: HSVA) => {
|
||||
hsva.value = value
|
||||
},
|
||||
displayMode: displayMode.value,
|
||||
'onUpdate:displayMode': (value: 'hex' | 'rgba') => {
|
||||
displayMode.value = value
|
||||
},
|
||||
alpha: true
|
||||
})
|
||||
)
|
||||
|
||||
render(Host, { global: { plugins: [i18n] } })
|
||||
|
||||
return { hsva, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
|
||||
|
||||
describe('ColorPickerPanel hex input', () => {
|
||||
it('updates the hsva model when a valid 6-digit hex is typed', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'ff0000')
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('accepts 3-digit shorthand hex', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, '0f0')
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('preserves the existing alpha when editing hex', async () => {
|
||||
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 40 })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'ffffff')
|
||||
|
||||
expect(hsva.value.a).toBe(40)
|
||||
})
|
||||
|
||||
it('ignores invalid input and leaves the model unchanged', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'nothex')
|
||||
|
||||
expect(hsva.value).toEqual(black)
|
||||
})
|
||||
|
||||
it('reformats the draft to canonical hex on blur', async () => {
|
||||
const { user } = renderPanel({ ...black })
|
||||
|
||||
const input = screen.getByRole<HTMLInputElement>('textbox', { name: 'Hex' })
|
||||
await user.clear(input)
|
||||
await user.type(input, '0f0')
|
||||
await user.tab()
|
||||
|
||||
expect(input.value).toBe('#00ff00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ColorPickerPanel rgba inputs', () => {
|
||||
it('updates the hsva model when an RGB channel is edited', async () => {
|
||||
const { hsva, user } = renderPanel({ ...black }, 'rgba')
|
||||
|
||||
const red = screen.getByRole('spinbutton', { name: 'Red' })
|
||||
await user.clear(red)
|
||||
await user.type(red, '255')
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('clamps an out-of-range channel to 255 on blur', async () => {
|
||||
const { user } = renderPanel({ ...black }, 'rgba')
|
||||
|
||||
const red = screen.getByRole<HTMLInputElement>('spinbutton', {
|
||||
name: 'Red'
|
||||
})
|
||||
await user.clear(red)
|
||||
await user.type(red, '300')
|
||||
await user.tab()
|
||||
|
||||
expect(red.value).toBe('255')
|
||||
})
|
||||
|
||||
it('edits alpha as a percentage', async () => {
|
||||
const { hsva, user } = renderPanel({ h: 0, s: 0, v: 0, a: 100 })
|
||||
|
||||
const alpha = screen.getByRole('spinbutton', { name: 'Alpha' })
|
||||
await user.clear(alpha)
|
||||
await user.type(alpha, '50')
|
||||
|
||||
expect(hsva.value.a).toBe(50)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const rgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-[211px] flex-col gap-2 rounded-lg border border-border-subtle bg-base-background p-2 shadow-md"
|
||||
@@ -36,79 +66,19 @@
|
||||
class="flex h-6 min-w-0 flex-1 items-center gap-1 rounded-sm bg-secondary-background px-1 text-xs text-node-component-slot-text"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<input
|
||||
v-model="hex.draft"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
:aria-label="t('color.hex')"
|
||||
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none"
|
||||
@focus="hex.beginEdit"
|
||||
@input="hex.commit"
|
||||
@keydown.enter="hex.commit"
|
||||
@blur="hex.reset"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate text-center">{{
|
||||
hexString
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
v-for="channel in rgbChannels"
|
||||
:key="channel.key"
|
||||
v-model.number="rgb.draft[channel.key]"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="255"
|
||||
:aria-label="t(channel.label)"
|
||||
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 text-center outline-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@focus="rgb.beginEdit"
|
||||
@input="rgb.commit"
|
||||
@keydown.enter="rgb.commit"
|
||||
@blur="rgb.reset"
|
||||
/>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.r }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<div
|
||||
v-if="alpha"
|
||||
class="flex shrink-0 items-center border-l border-border-subtle pl-1"
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
<input
|
||||
v-model.number="alphaField.draft"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:aria-label="t('color.alpha')"
|
||||
class="w-6 min-w-0 appearance-none border-none bg-transparent p-0 text-right outline-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@focus="alphaField.beginEdit"
|
||||
@input="alphaField.commit"
|
||||
@keydown.enter="alphaField.commit"
|
||||
@blur="alphaField.reset"
|
||||
/>
|
||||
<span>%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Select from '@/components/ui/select/Select.vue'
|
||||
import SelectContent from '@/components/ui/select/SelectContent.vue'
|
||||
import SelectItem from '@/components/ui/select/SelectItem.vue'
|
||||
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
|
||||
import SelectValue from '@/components/ui/select/SelectValue.vue'
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
import { rgbChannels, useColorPicker } from './useColorPicker'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hex, rgb, alpha: alphaField } = useColorPicker(hsva)
|
||||
</script>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
|
||||
import { useColorPicker } from './useColorPicker'
|
||||
|
||||
const black: HSVA = { h: 0, s: 0, v: 0, a: 100 }
|
||||
|
||||
function setup(initial: HSVA) {
|
||||
const hsva = ref<HSVA>(initial)
|
||||
const scope = effectScope()
|
||||
const api = scope.run(() => useColorPicker(hsva))!
|
||||
return { hsva, api, stop: () => scope.stop() }
|
||||
}
|
||||
|
||||
describe('useColorPicker', () => {
|
||||
it('commits a valid hex and ignores malformed input', () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
api.hex.draft = '#00ff00'
|
||||
api.hex.commit()
|
||||
expect(hsva.value).toMatchObject({ h: 120, s: 100, v: 100 })
|
||||
|
||||
const unchanged = hsva.value
|
||||
api.hex.draft = 'zzz'
|
||||
api.hex.commit()
|
||||
expect(hsva.value).toBe(unchanged)
|
||||
})
|
||||
|
||||
it('clamps rgb channels before converting to hsv', () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
api.rgb.draft.r = 300
|
||||
api.rgb.commit()
|
||||
|
||||
expect(hsva.value).toMatchObject({ h: 0, s: 100, v: 100 })
|
||||
})
|
||||
|
||||
it('clamps alpha to the 0..100 range', () => {
|
||||
const { hsva, api } = setup({ h: 0, s: 0, v: 0, a: 100 })
|
||||
|
||||
api.alpha.draft = 150
|
||||
api.alpha.commit()
|
||||
|
||||
expect(hsva.value.a).toBe(100)
|
||||
})
|
||||
|
||||
it('syncs the draft from the model when not editing', async () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
hsva.value = { ...hsva.value, h: 0, s: 100, v: 100 }
|
||||
await nextTick()
|
||||
|
||||
expect(api.hex.draft).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('freezes the draft against external model changes while editing', async () => {
|
||||
const { hsva, api } = setup({ ...black })
|
||||
|
||||
api.hex.beginEdit()
|
||||
api.hex.draft = '#123456'
|
||||
hsva.value = { h: 0, s: 100, v: 100, a: 100 }
|
||||
await nextTick()
|
||||
|
||||
expect(api.hex.draft).toBe('#123456')
|
||||
})
|
||||
|
||||
it('resyncs the draft to the model on reset', () => {
|
||||
const { api } = setup({ ...black })
|
||||
|
||||
api.hex.beginEdit()
|
||||
api.hex.draft = '#abcdef'
|
||||
api.hex.reset()
|
||||
|
||||
expect(api.hex.draft).toBe('#000000')
|
||||
})
|
||||
})
|
||||
@@ -1,79 +0,0 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { HSVA } from '@/utils/colorUtil'
|
||||
import {
|
||||
hexToHsva,
|
||||
hsbToRgb,
|
||||
normalizeHex,
|
||||
rgbToHex,
|
||||
rgbToHsv
|
||||
} from '@/utils/colorUtil'
|
||||
|
||||
export const rgbChannels = [
|
||||
{ key: 'r', label: 'color.red' },
|
||||
{ key: 'g', label: 'color.green' },
|
||||
{ key: 'b', label: 'color.blue' }
|
||||
] as const
|
||||
|
||||
function useDraftField<T>(source: () => T, apply: (draft: T) => void) {
|
||||
const draft = ref(source()) as Ref<T>
|
||||
const isEditing = ref(false)
|
||||
|
||||
watch(source, (value) => {
|
||||
if (!isEditing.value) draft.value = value
|
||||
})
|
||||
|
||||
return reactive({
|
||||
draft,
|
||||
beginEdit: () => {
|
||||
isEditing.value = true
|
||||
},
|
||||
commit: () => apply(draft.value),
|
||||
reset: () => {
|
||||
isEditing.value = false
|
||||
draft.value = source()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useColorPicker(hsva: Ref<HSVA>) {
|
||||
const rgb = computed(() =>
|
||||
hsbToRgb({ h: hsva.value.h, s: hsva.value.s, b: hsva.value.v })
|
||||
)
|
||||
const hexString = computed(() => rgbToHex(rgb.value).toLowerCase())
|
||||
|
||||
const hex = useDraftField(
|
||||
() => hexString.value,
|
||||
(draft) => {
|
||||
const normalized = normalizeHex(draft)
|
||||
if (!normalized) return
|
||||
const next = hexToHsva(normalized)
|
||||
hsva.value = { ...hsva.value, h: next.h, s: next.s, v: next.v }
|
||||
}
|
||||
)
|
||||
|
||||
const rgbField = useDraftField(
|
||||
() => ({ ...rgb.value }),
|
||||
({ r, g, b }) => {
|
||||
if (![r, g, b].every(Number.isFinite)) return
|
||||
const hsv = rgbToHsv({
|
||||
r: clamp(Math.round(r), 0, 255),
|
||||
g: clamp(Math.round(g), 0, 255),
|
||||
b: clamp(Math.round(b), 0, 255)
|
||||
})
|
||||
hsva.value = { ...hsva.value, h: hsv.h, s: hsv.s, v: hsv.v }
|
||||
}
|
||||
)
|
||||
|
||||
const alpha = useDraftField(
|
||||
() => hsva.value.a,
|
||||
(draft) => {
|
||||
if (!Number.isFinite(draft)) return
|
||||
hsva.value = { ...hsva.value, a: clamp(Math.round(draft), 0, 100) }
|
||||
}
|
||||
)
|
||||
|
||||
return { hex, rgb: rgbField, alpha }
|
||||
}
|
||||
@@ -112,5 +112,13 @@ export interface BillingContext extends BillingState, BillingActions {
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
*/
|
||||
isLegacyTeamPlan: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the subscription is a team plan of either generation. Unlike
|
||||
* `isLegacyTeamPlan` this does not require an active subscription: the spend
|
||||
* gate folds billing_status into is_active, so a paused or payment-failed team
|
||||
* plan reports is_active=false and must still read as a team plan.
|
||||
*/
|
||||
isTeamPlan: ComputedRef<boolean>
|
||||
getMaxSeats: (tierKey: TierKey) => number
|
||||
canRunWorkflows: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
|
||||
|
||||
const {
|
||||
mockTeamWorkspacesEnabled,
|
||||
mockConsolidatedBillingEnabled,
|
||||
mockBillingControlEnabled,
|
||||
mockIsPersonal,
|
||||
mockPlans,
|
||||
mockPurchaseCredits,
|
||||
@@ -27,7 +27,7 @@ const {
|
||||
mockBillingStatus
|
||||
} = vi.hoisted(() => ({
|
||||
mockTeamWorkspacesEnabled: { value: false },
|
||||
mockConsolidatedBillingEnabled: { value: false },
|
||||
mockBillingControlEnabled: { value: false },
|
||||
mockIsPersonal: { value: true },
|
||||
mockPlans: { value: [] as Plan[] },
|
||||
mockPurchaseCredits: vi.fn(),
|
||||
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
teamWorkspacesEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
const consolidatedBillingEnabledRef = ref(
|
||||
mockConsolidatedBillingEnabled.value
|
||||
)
|
||||
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
|
||||
get: () => consolidatedBillingEnabledRef.value,
|
||||
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
|
||||
Object.defineProperty(mockBillingControlEnabled, 'value', {
|
||||
get: () => billingControlEnabledRef.value,
|
||||
set: (value: boolean) => {
|
||||
consolidatedBillingEnabledRef.value = value
|
||||
billingControlEnabledRef.value = value
|
||||
}
|
||||
})
|
||||
return {
|
||||
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
|
||||
get teamWorkspacesEnabled() {
|
||||
return mockTeamWorkspacesEnabled.value
|
||||
},
|
||||
get consolidatedBillingEnabled() {
|
||||
return mockConsolidatedBillingEnabled.value
|
||||
get billingControlEnabled() {
|
||||
return mockBillingControlEnabled.value
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockTeamWorkspacesEnabled.value = false
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
mockPlans.value = []
|
||||
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
|
||||
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('selects workspace type for personal when consolidated billing is enabled', () => {
|
||||
it('selects workspace type for personal when billing control is enabled', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
expect(type.value).toBe('workspace')
|
||||
})
|
||||
|
||||
it('selects workspace type for team regardless of consolidated billing', () => {
|
||||
it('selects workspace type for team regardless of billing control', () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = false
|
||||
|
||||
const { type } = useBillingContext()
|
||||
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
|
||||
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
|
||||
|
||||
// Authenticated remote config resolves the flag on for the same workspace
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
|
||||
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = false
|
||||
mockBillingControlEnabled.value = false
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { type } = useBillingContext()
|
||||
await nextTick()
|
||||
expect(type.value).toBe('legacy')
|
||||
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(type.value).toBe('workspace')
|
||||
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
|
||||
})
|
||||
|
||||
describe('subscription mirror to workspace store', () => {
|
||||
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
|
||||
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockConsolidatedBillingEnabled.value = true
|
||||
mockBillingControlEnabled.value = true
|
||||
mockIsPersonal.value = true
|
||||
|
||||
const { initialize } = useBillingContext()
|
||||
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
|
||||
expect(isLegacyTeamPlan.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTeamPlan', () => {
|
||||
it('is false for a personal workspace', () => {
|
||||
const { isTeamPlan } = useBillingContext()
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
|
||||
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
|
||||
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
|
||||
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
|
||||
// isTeamPlan reads the credit stop and the slug, never the tier — which is
|
||||
// what keeps it working despite that divergence.
|
||||
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
// The banner states that need isTeamPlan most — paused and payment_failed —
|
||||
// are exactly the ones the backend reports with is_active=false, because the
|
||||
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
|
||||
// subscription would blank the banner precisely when it is needed.
|
||||
it('stays true for a paused team plan, which the backend reports inactive', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'paused',
|
||||
plan_slug: 'team_per_credit_monthly',
|
||||
team_credit_stop: {
|
||||
id: 'team_700',
|
||||
credits_monthly: 700,
|
||||
stop_usd: 332
|
||||
}
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true for a legacy team plan whose payment failed', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: false,
|
||||
has_funds: true,
|
||||
billing_status: 'payment_failed',
|
||||
subscription_tier: 'STANDARD',
|
||||
plan_slug: 'team-standard-annual'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a team workspace on a personal-tier plan', async () => {
|
||||
mockTeamWorkspacesEnabled.value = true
|
||||
mockIsPersonal.value = false
|
||||
mockBillingStatus.value = {
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_tier: 'PRO',
|
||||
plan_slug: 'pro-monthly'
|
||||
}
|
||||
|
||||
const { initialize, isTeamPlan } = useBillingContext()
|
||||
await initialize()
|
||||
|
||||
expect(isTeamPlan.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getTierFeatures
|
||||
} from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
|
||||
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
|
||||
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
|
||||
import type {
|
||||
PreviewSubscribeOptions,
|
||||
@@ -35,8 +36,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
|
||||
*
|
||||
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
|
||||
* - Team workspaces enabled: workspace billing via /api/billing/* for team
|
||||
* workspaces, and for personal workspaces once consolidated billing is
|
||||
* enabled; personal workspaces otherwise stay on legacy billing
|
||||
* workspaces, and for personal workspaces once billing control is enabled;
|
||||
* personal workspaces otherwise stay on legacy billing
|
||||
*
|
||||
* The context automatically initializes when the workspace changes and provides
|
||||
* a unified interface for subscription status, balance, and billing actions.
|
||||
@@ -129,6 +130,16 @@ function useBillingContextInternal(): BillingContext {
|
||||
|
||||
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
|
||||
|
||||
const freeTierQuota = useFreeTierQuota()
|
||||
|
||||
const canRunWorkflows = computed(
|
||||
() =>
|
||||
isActiveSubscription.value &&
|
||||
(!isFreeTier.value ||
|
||||
!freeTierQuota.quotaEnabled.value ||
|
||||
freeTierQuota.freeTierExecutionPermitted.value)
|
||||
)
|
||||
|
||||
const isLegacyTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
@@ -141,6 +152,21 @@ function useBillingContextInternal(): BillingContext {
|
||||
false)
|
||||
)
|
||||
|
||||
// Plan identity, independent of subscription health: the per-credit Team plan
|
||||
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
|
||||
// isActiveSubscription on purpose — paused and payment_failed both force
|
||||
// is_active=false, which is exactly when callers still need to know this is a
|
||||
// team plan.
|
||||
const isTeamPlan = computed(
|
||||
() =>
|
||||
type.value === 'workspace' &&
|
||||
(currentTeamCreditStop.value !== null ||
|
||||
(currentPlanSlug.value
|
||||
?.toLowerCase()
|
||||
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
|
||||
false))
|
||||
)
|
||||
|
||||
const billingStatus = computed(() =>
|
||||
toValue(activeContext.value.billingStatus)
|
||||
)
|
||||
@@ -191,9 +217,9 @@ function useBillingContextInternal(): BillingContext {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// type flips when the team-workspaces or consolidated-billing flag resolves
|
||||
// from authenticated config, swapping the active backend. Reset then reinit
|
||||
// on every workspace-id or type change.
|
||||
// type flips when the team-workspaces or billing-control flag resolves from
|
||||
// authenticated config, swapping the active backend. Reset then reinit on
|
||||
// every workspace-id or type change.
|
||||
watch(
|
||||
[() => store.activeWorkspace?.id, () => type.value],
|
||||
async ([newWorkspaceId]) => {
|
||||
@@ -297,8 +323,10 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLoading,
|
||||
error,
|
||||
isActiveSubscription,
|
||||
canRunWorkflows,
|
||||
isFreeTier,
|
||||
isLegacyTeamPlan,
|
||||
isTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
tier,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
|
||||
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
|
||||
mockFlags: {
|
||||
teamWorkspacesEnabled: false,
|
||||
consolidatedBillingEnabled: false
|
||||
billingControlEnabled: false
|
||||
},
|
||||
mockActiveWorkspace: {
|
||||
value: null as { id: string; type: 'personal' | 'team' } | null
|
||||
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
|
||||
describe('useBillingRouting', () => {
|
||||
beforeEach(() => {
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
})
|
||||
|
||||
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps personal on legacy when consolidated billing is disabled', () => {
|
||||
it('keeps personal on legacy when billing control is disabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
|
||||
expect(type.value).toBe('legacy')
|
||||
})
|
||||
|
||||
it('moves personal to workspace billing when consolidated billing is enabled', () => {
|
||||
it('moves personal to workspace billing when billing control is enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockActiveWorkspace.value = personal
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
|
||||
it('uses workspace billing for team workspaces regardless of billing control', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = false
|
||||
mockFlags.billingControlEnabled = false
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
|
||||
expect(shouldUseWorkspaceBilling.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
|
||||
it('uses workspace billing for team workspaces with billing control enabled', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockActiveWorkspace.value = team
|
||||
|
||||
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
|
||||
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
|
||||
|
||||
it('defaults to legacy while the workspace has not loaded', () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.consolidatedBillingEnabled = true
|
||||
mockFlags.billingControlEnabled = true
|
||||
mockActiveWorkspace.value = null
|
||||
|
||||
const { type } = useBillingRouting()
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { BillingType } from './types'
|
||||
/**
|
||||
* Selects the billing backend for the active workspace: legacy user-scoped
|
||||
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
|
||||
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
|
||||
* stay legacy until `billingControlEnabled`; team workspaces are always
|
||||
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
|
||||
*/
|
||||
export function useBillingRouting() {
|
||||
@@ -23,7 +23,7 @@ export function useBillingRouting() {
|
||||
const workspaceType = workspaceStore.activeWorkspace?.type
|
||||
if (!workspaceType) return 'legacy'
|
||||
|
||||
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
|
||||
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
|
||||
return 'legacy'
|
||||
}
|
||||
|
||||
|
||||
193
src/composables/maskeditor/useMaskEditorLoader.test.ts
Normal file
193
src/composables/maskeditor/useMaskEditorLoader.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useMaskEditorLoader } from './useMaskEditorLoader'
|
||||
|
||||
// ---- Module Mocks ----
|
||||
|
||||
const mockDataStore: Record<string, unknown> = {
|
||||
inputData: null,
|
||||
sourceNode: null,
|
||||
setLoading: vi.fn()
|
||||
}
|
||||
|
||||
vi.mock('@/stores/maskEditorDataStore', () => ({
|
||||
useMaskEditorDataStore: vi.fn(() => mockDataStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: vi.fn(() => ({
|
||||
getNodeOutputs: vi.fn(() => undefined)
|
||||
}))
|
||||
}))
|
||||
|
||||
const distribution = vi.hoisted(() => ({ isCloud: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distribution.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
apiURL: vi.fn((route: string) => `http://localhost:8188/api${route}`)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
getPreviewFormatParam: vi.fn(() => ''),
|
||||
getRandParam: vi.fn(() => '')
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Image constructor so the loader's image fetches resolve without a
|
||||
// network. Records every requested URL; URLs matching failUrlPattern reject
|
||||
// like a 404 would (reset per test in beforeEach).
|
||||
const requestedUrls: string[] = []
|
||||
let failUrlPattern: RegExp | null = null
|
||||
|
||||
class MockImage {
|
||||
crossOrigin = ''
|
||||
onload: ((ev: Event) => void) | null = null
|
||||
onerror: ((ev: unknown) => void) | null = null
|
||||
private _src = ''
|
||||
get src() {
|
||||
return this._src
|
||||
}
|
||||
set src(value: string) {
|
||||
this._src = value
|
||||
requestedUrls.push(value)
|
||||
queueMicrotask(() => {
|
||||
if (failUrlPattern?.test(value)) this.onerror?.(new Event('error'))
|
||||
else this.onload?.(new Event('load'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function createLoadImageNode(widgetValue: string): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id: 7,
|
||||
type: 'LoadImage',
|
||||
imgs: [{ src: 'http://localhost:8188/api/view?filename=whatever.png' }],
|
||||
images: undefined,
|
||||
widgets: [{ name: 'image', value: widgetValue }]
|
||||
})
|
||||
}
|
||||
|
||||
function subfolderOf(url: string): string | null {
|
||||
return new URL(url).searchParams.get('subfolder')
|
||||
}
|
||||
|
||||
function requestedLayerUrls(layerFilename: string): string[] {
|
||||
return requestedUrls.filter((url) => url.includes(layerFilename))
|
||||
}
|
||||
|
||||
describe('useMaskEditorLoader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requestedUrls.length = 0
|
||||
failUrlPattern = null
|
||||
distribution.isCloud = false
|
||||
mockDataStore.inputData = null
|
||||
mockDataStore.sourceNode = null
|
||||
vi.stubGlobal('Image', MockImage)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('loads layer files from the input root for saves without a subfolder prefix', async () => {
|
||||
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
for (const layerFilename of [
|
||||
'clipspace-mask-123.png',
|
||||
'clipspace-paint-123.png'
|
||||
]) {
|
||||
const layerUrls = requestedLayerUrls(layerFilename)
|
||||
expect(layerUrls.length).toBeGreaterThan(0)
|
||||
for (const url of layerUrls) {
|
||||
expect(subfolderOf(url)).toBeNull()
|
||||
}
|
||||
}
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
sourceRef: { filename: 'clipspace-mask-123.png', type: 'input' },
|
||||
nodeId: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('loads layer files from the clipspace subfolder for legacy saves', async () => {
|
||||
const node = createLoadImageNode(
|
||||
'clipspace/clipspace-painted-masked-123.png [input]'
|
||||
)
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
for (const layerFilename of [
|
||||
'clipspace-mask-123.png',
|
||||
'clipspace-paint-123.png'
|
||||
]) {
|
||||
const layerUrls = requestedLayerUrls(layerFilename)
|
||||
expect(layerUrls.length).toBeGreaterThan(0)
|
||||
for (const url of layerUrls) {
|
||||
expect(subfolderOf(url)).toBe('clipspace')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the clipspace subfolder for cloud-resolved layer files', async () => {
|
||||
distribution.isCloud = true
|
||||
vi.mocked(api.fetchApi).mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
painted_masked: 'hash-painted-masked.png',
|
||||
painted: 'hash-painted.png',
|
||||
paint: 'hash-paint.png',
|
||||
mask: 'hash-mask.png'
|
||||
})
|
||||
} as Response)
|
||||
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
for (const layerFilename of ['hash-painted-masked.png', 'hash-paint.png']) {
|
||||
const layerUrls = requestedLayerUrls(layerFilename)
|
||||
expect(layerUrls.length).toBeGreaterThan(0)
|
||||
for (const url of layerUrls) {
|
||||
expect(subfolderOf(url)).toBe('clipspace')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the node image when layer files are missing', async () => {
|
||||
failUrlPattern = /clipspace-(mask|paint)-123\.png/
|
||||
const node = createLoadImageNode('clipspace-painted-masked-123.png [input]')
|
||||
|
||||
await useMaskEditorLoader().loadFromNode(node)
|
||||
|
||||
expect(mockDataStore.inputData).toMatchObject({
|
||||
sourceRef: { filename: 'clipspace-painted-masked-123.png' },
|
||||
paintLayer: undefined,
|
||||
nodeId: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('still fails when the node image itself cannot be loaded', async () => {
|
||||
failUrlPattern = /./
|
||||
const node = createLoadImageNode('some-regular-image.png [input]')
|
||||
|
||||
await expect(useMaskEditorLoader().loadFromNode(node)).rejects.toThrow()
|
||||
expect(mockDataStore.setLoading).toHaveBeenLastCalledWith(
|
||||
false,
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -53,10 +53,10 @@ function imageLayerFilenamesIfApplicable(
|
||||
}
|
||||
}
|
||||
|
||||
function toRef(filename: string): ImageRef {
|
||||
function toRef(filename: string, subfolder: string): ImageRef {
|
||||
return {
|
||||
filename,
|
||||
subfolder: 'clipspace',
|
||||
subfolder,
|
||||
type: 'input'
|
||||
}
|
||||
}
|
||||
@@ -150,34 +150,72 @@ export function useMaskEditorLoader() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseImageUrl = imageLayerFilenames?.maskedImage
|
||||
? mkFileUrl({ ref: toRef(imageLayerFilenames.maskedImage) })
|
||||
: nodeImageUrl
|
||||
// Pattern-matched layer files live next to the painted-masked image the
|
||||
// widget points at: the input root for saves under the unified upload
|
||||
// contract (#12318), or the clipspace subfolder for saves made before
|
||||
// it. Cloud resolves the hash filenames from its mask-layers API
|
||||
// server-side, so its refs keep the subfolder they always used.
|
||||
const layerSubfolder = maskLayersFromApi
|
||||
? 'clipspace'
|
||||
: (nodeImageRef.subfolder ?? '')
|
||||
|
||||
const sourceRef = imageLayerFilenames?.maskedImage
|
||||
? parseImageRef(baseImageUrl)
|
||||
: nodeImageRef
|
||||
const layeredBaseUrl = imageLayerFilenames?.maskedImage
|
||||
? mkFileUrl({
|
||||
ref: toRef(imageLayerFilenames.maskedImage, layerSubfolder)
|
||||
})
|
||||
: undefined
|
||||
const baseImageUrl = layeredBaseUrl ?? nodeImageUrl
|
||||
|
||||
let paintLayerUrl: string | null = null
|
||||
if (maskLayersFromApi?.paint) {
|
||||
paintLayerUrl = mkFileUrl({ ref: toRef(maskLayersFromApi.paint) })
|
||||
paintLayerUrl = mkFileUrl({
|
||||
ref: toRef(maskLayersFromApi.paint, layerSubfolder)
|
||||
})
|
||||
} else if (imageLayerFilenames?.paint) {
|
||||
paintLayerUrl = mkFileUrl({ ref: toRef(imageLayerFilenames.paint) })
|
||||
paintLayerUrl = mkFileUrl({
|
||||
ref: toRef(imageLayerFilenames.paint, layerSubfolder)
|
||||
})
|
||||
}
|
||||
|
||||
const [baseLayer, maskLayer, paintLayer] = await Promise.all([
|
||||
loadImageLayer(baseImageUrl, 'rgb'),
|
||||
loadImageLayer(baseImageUrl, 'a'),
|
||||
paintLayerUrl
|
||||
? loadPaintLayer(paintLayerUrl)
|
||||
: Promise.resolve(undefined)
|
||||
])
|
||||
const loadLayers = (baseUrl: string, paintUrl: string | null) =>
|
||||
Promise.all([
|
||||
loadImageLayer(baseUrl, 'rgb'),
|
||||
loadImageLayer(baseUrl, 'a'),
|
||||
paintUrl ? loadPaintLayer(paintUrl) : Promise.resolve(undefined)
|
||||
])
|
||||
|
||||
const loadInputData = async () => {
|
||||
try {
|
||||
const [baseLayer, maskLayer, paintLayer] = await loadLayers(
|
||||
baseImageUrl,
|
||||
paintLayerUrl
|
||||
)
|
||||
return {
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer,
|
||||
sourceRef: layeredBaseUrl
|
||||
? parseImageRef(baseImageUrl)
|
||||
: nodeImageRef
|
||||
}
|
||||
} catch (error) {
|
||||
if (!layeredBaseUrl) throw error
|
||||
console.warn(
|
||||
'[MaskEditorLoader] Failed to load editor layer files, falling back to the node image',
|
||||
error
|
||||
)
|
||||
const [baseLayer, maskLayer] = await loadLayers(nodeImageUrl, null)
|
||||
return {
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer: undefined,
|
||||
sourceRef: nodeImageRef
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataStore.inputData = {
|
||||
baseLayer,
|
||||
maskLayer,
|
||||
paintLayer,
|
||||
sourceRef,
|
||||
...(await loadInputData()),
|
||||
nodeId: node.id
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { mapAllNodes } from '@/utils/graphTraversalUtil'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: SubgraphInput
|
||||
@@ -150,3 +157,20 @@ export const usePriceBadge = () => {
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
export const useCreditsBadgesInGraph = createSharedComposable(() => {
|
||||
const { isCreditsBadge } = usePriceBadge()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
return computed(() => {
|
||||
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
|
||||
if (!app.graph) return []
|
||||
return mapAllNodes(app.graph, (node) => {
|
||||
if (node.isSubgraphNode()) return
|
||||
|
||||
const priceBadge = node.badges.find(isCreditsBadge)
|
||||
if (!priceBadge) return
|
||||
|
||||
trackNodePrice(node)
|
||||
return [node.title, toValue(priceBadge).text, node.id] as const
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
241
src/composables/useCameraInfo.test.ts
Normal file
241
src/composables/useCameraInfo.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
interface ViewportInstance {
|
||||
ctorArgs: unknown[]
|
||||
applyState: ReturnType<typeof vi.fn>
|
||||
setGizmosVisible: ReturnType<typeof vi.fn>
|
||||
setTransformGizmoMode: ReturnType<typeof vi.fn>
|
||||
setLookThrough: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
viewport: {
|
||||
updateStatusMouseOnScene: ReturnType<typeof vi.fn>
|
||||
updateStatusMouseOnNode: ReturnType<typeof vi.fn>
|
||||
refreshViewport: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
const { ViewportMock, instances, addAlert } = vi.hoisted(() => {
|
||||
const instances: ViewportInstance[] = []
|
||||
const ViewportMock = vi.fn(function (...ctorArgs: unknown[]) {
|
||||
const instance: ViewportInstance = {
|
||||
ctorArgs,
|
||||
applyState: vi.fn(),
|
||||
setGizmosVisible: vi.fn(),
|
||||
setTransformGizmoMode: vi.fn(),
|
||||
setLookThrough: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
viewport: {
|
||||
updateStatusMouseOnScene: vi.fn(),
|
||||
updateStatusMouseOnNode: vi.fn(),
|
||||
refreshViewport: vi.fn()
|
||||
}
|
||||
}
|
||||
instances.push(instance)
|
||||
return instance
|
||||
})
|
||||
return { ViewportMock, instances, addAlert: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/core/cameraInfo/CameraInfoViewport', () => ({
|
||||
CameraInfoViewport: ViewportMock
|
||||
}))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
import { useCameraInfo } from './useCameraInfo'
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
callback?: (value: unknown, ...rest: unknown[]) => void
|
||||
}
|
||||
|
||||
interface FakeNode {
|
||||
widgets: FakeWidget[]
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
|
||||
function makeNode(values: Record<string, unknown>): FakeNode {
|
||||
return {
|
||||
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
}
|
||||
|
||||
function widget(node: FakeNode, name: string): FakeWidget {
|
||||
const found = node.widgets.find((w) => w.name === name)
|
||||
if (!found) throw new Error(`missing widget ${name}`)
|
||||
return found
|
||||
}
|
||||
|
||||
function nodeRef(node: FakeNode) {
|
||||
return ref(node) as unknown as Ref<LGraphNode | null>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
instances.length = 0
|
||||
ViewportMock.mockClear()
|
||||
addAlert.mockClear()
|
||||
})
|
||||
|
||||
describe('useCameraInfo', () => {
|
||||
it('constructs the viewport from widget state and exposes the mode', () => {
|
||||
const node = makeNode({ mode: 'look_at', 'mode.distance': 7 })
|
||||
const container = document.createElement('div')
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
|
||||
camera.initialize(container)
|
||||
|
||||
expect(ViewportMock).toHaveBeenCalledOnce()
|
||||
const [ctorContainer, initialState] = instances[0].ctorArgs as [
|
||||
HTMLElement,
|
||||
{ mode: string; orbit: { distance: number } }
|
||||
]
|
||||
expect(ctorContainer).toBe(container)
|
||||
expect(initialState.mode).toBe('look_at')
|
||||
expect(initialState.orbit.distance).toBe(7)
|
||||
expect(camera.mode.value).toBe('look_at')
|
||||
})
|
||||
|
||||
it('does nothing when the node is null', () => {
|
||||
const camera = useCameraInfo(ref(null))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
expect(ViewportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('alerts and does not throw when the viewport fails to construct', () => {
|
||||
ViewportMock.mockImplementationOnce(() => {
|
||||
throw new Error('webgl unavailable')
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
|
||||
|
||||
expect(() => camera.initialize(document.createElement('div'))).not.toThrow()
|
||||
expect(addAlert).toHaveBeenCalledOnce()
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards toolbar actions to the viewport', () => {
|
||||
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
camera.setGizmosVisible(false)
|
||||
camera.setTransformGizmoMode('camera-rotate')
|
||||
camera.setLookThrough(true)
|
||||
|
||||
expect(instances[0].setGizmosVisible).toHaveBeenCalledWith(false)
|
||||
expect(instances[0].setTransformGizmoMode).toHaveBeenCalledWith(
|
||||
'camera-rotate'
|
||||
)
|
||||
expect(instances[0].setLookThrough).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('ignores toolbar actions before initialization', () => {
|
||||
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
|
||||
|
||||
expect(() => camera.setLookThrough(true)).not.toThrow()
|
||||
expect(instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-applies state to the viewport when a widget changes, keeping the original callback', () => {
|
||||
const node = makeNode({ mode: 'orbit', target_x: 0 })
|
||||
const original = vi.fn()
|
||||
widget(node, 'target_x').callback = original
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
const targetX = widget(node, 'target_x')
|
||||
targetX.value = 3
|
||||
targetX.callback!(3)
|
||||
|
||||
expect(original).toHaveBeenCalledWith(3)
|
||||
const applied = instances[0].applyState.mock.lastCall?.[0] as {
|
||||
target: { x: number }
|
||||
}
|
||||
expect(applied.target.x).toBe(3)
|
||||
})
|
||||
|
||||
it('updates the mode ref when the mode widget changes', () => {
|
||||
const node = makeNode({ mode: 'orbit' })
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
const modeWidget = widget(node, 'mode')
|
||||
modeWidget.value = 'quaternion'
|
||||
modeWidget.callback!('quaternion')
|
||||
|
||||
expect(camera.mode.value).toBe('quaternion')
|
||||
})
|
||||
|
||||
it('routes node hover into the viewport status flags', () => {
|
||||
const node = makeNode({ mode: 'orbit' })
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
camera.handleMouseEnter()
|
||||
camera.handleMouseLeave()
|
||||
node.onMouseEnter?.()
|
||||
|
||||
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
|
||||
true
|
||||
)
|
||||
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
|
||||
false
|
||||
)
|
||||
expect(instances[0].viewport.updateStatusMouseOnNode).toHaveBeenCalledWith(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('removes the viewport and restores widget callbacks on cleanup', () => {
|
||||
const node = makeNode({ mode: 'orbit', target_x: 0 })
|
||||
const original = vi.fn()
|
||||
widget(node, 'target_x').callback = original
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
camera.cleanup()
|
||||
|
||||
expect(instances[0].remove).toHaveBeenCalledOnce()
|
||||
expect(widget(node, 'target_x').callback).toBe(original)
|
||||
})
|
||||
|
||||
it('restores the node mouse handlers on cleanup', () => {
|
||||
const node = makeNode({ mode: 'orbit' })
|
||||
const originalEnter = vi.fn()
|
||||
const originalLeave = vi.fn()
|
||||
node.onMouseEnter = originalEnter
|
||||
node.onMouseLeave = originalLeave
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
expect(node.onMouseEnter).not.toBe(originalEnter)
|
||||
|
||||
camera.cleanup()
|
||||
|
||||
expect(node.onMouseEnter).toBe(originalEnter)
|
||||
expect(node.onMouseLeave).toBe(originalLeave)
|
||||
})
|
||||
|
||||
it('re-wires widgets on a second initialize after cleanup', () => {
|
||||
const node = makeNode({ mode: 'orbit', target_x: 0 })
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
camera.cleanup()
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
const targetX = widget(node, 'target_x')
|
||||
targetX.value = 5
|
||||
targetX.callback!(5)
|
||||
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(instances[1].applyState).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
170
src/composables/useCameraInfo.ts
Normal file
170
src/composables/useCameraInfo.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { ref, toRaw, toRef } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { CameraInfoViewport } from '@/extensions/core/cameraInfo/CameraInfoViewport'
|
||||
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from '@/extensions/core/cameraInfo/types'
|
||||
import type { CameraInfoMode } from '@/extensions/core/cameraInfo/types'
|
||||
import {
|
||||
readStateFromWidgets,
|
||||
writeWidgetValue
|
||||
} from '@/extensions/core/cameraInfo/widgetBridge'
|
||||
import type { NodeWithWidgets } from '@/extensions/core/cameraInfo/widgetBridge'
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
type WidgetCallback = (value: unknown, ...rest: unknown[]) => void
|
||||
interface MutableWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
callback?: WidgetCallback
|
||||
}
|
||||
|
||||
const WIDGET_NAMES = [
|
||||
'mode',
|
||||
'camera_type',
|
||||
'target_x',
|
||||
'target_y',
|
||||
'target_z',
|
||||
'roll',
|
||||
'fov',
|
||||
'zoom',
|
||||
'mode.yaw',
|
||||
'mode.pitch',
|
||||
'mode.distance',
|
||||
'mode.position_x',
|
||||
'mode.position_y',
|
||||
'mode.position_z',
|
||||
'mode.quat_x',
|
||||
'mode.quat_y',
|
||||
'mode.quat_z',
|
||||
'mode.quat_w'
|
||||
] as const
|
||||
|
||||
export function useCameraInfo(nodeRef: MaybeRef<LGraphNode | null>) {
|
||||
const node = toRef(nodeRef)
|
||||
let viewport: CameraInfoViewport | null = null
|
||||
let wiredNode: LGraphNode | null = null
|
||||
let originalOnMouseEnter: LGraphNode['onMouseEnter']
|
||||
let originalOnMouseLeave: LGraphNode['onMouseLeave']
|
||||
|
||||
const mode = ref<CameraInfoMode>(DEFAULT_CAMERA_INFO_STATE.mode)
|
||||
|
||||
const wrappedWidgets: { widget: MutableWidget; original?: WidgetCallback }[] =
|
||||
[]
|
||||
const wrappedSet = new WeakSet<MutableWidget>()
|
||||
|
||||
const initialize = (container: HTMLElement): void => {
|
||||
const raw = toRaw(node.value)
|
||||
if (!raw || !container) return
|
||||
if (viewport) cleanup()
|
||||
|
||||
try {
|
||||
const initialState = readStateFromWidgets(raw as NodeWithWidgets)
|
||||
mode.value = initialState.mode
|
||||
viewport = new CameraInfoViewport(container, initialState, {
|
||||
onHandleDrag: (fieldName, value) => {
|
||||
writeWidgetValue(raw as NodeWithWidgets, fieldName, value)
|
||||
}
|
||||
})
|
||||
wireWidgetsToOverlay(raw as NodeWithWidgets)
|
||||
wireNodeMouseStatus(raw as LGraphNode)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize CameraInfoViewport:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.failedToInitializeCameraInfoViewer')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
unwireWidgets()
|
||||
unwireNodeMouseStatus()
|
||||
viewport?.remove()
|
||||
viewport = null
|
||||
}
|
||||
|
||||
const handleMouseEnter = (): void => {
|
||||
viewport?.viewport.updateStatusMouseOnScene(true)
|
||||
viewport?.viewport.refreshViewport()
|
||||
}
|
||||
|
||||
const handleMouseLeave = (): void => {
|
||||
viewport?.viewport.updateStatusMouseOnScene(false)
|
||||
}
|
||||
|
||||
const setGizmosVisible = (on: boolean): void => {
|
||||
viewport?.setGizmosVisible(on)
|
||||
}
|
||||
|
||||
const setTransformGizmoMode = (gizmoMode: TransformGizmoMode): void => {
|
||||
viewport?.setTransformGizmoMode(gizmoMode)
|
||||
}
|
||||
|
||||
const setLookThrough = (on: boolean): void => {
|
||||
viewport?.setLookThrough(on)
|
||||
}
|
||||
|
||||
function wireNodeMouseStatus(target: LGraphNode): void {
|
||||
wiredNode = target
|
||||
originalOnMouseEnter = target.onMouseEnter
|
||||
originalOnMouseLeave = target.onMouseLeave
|
||||
target.onMouseEnter = useChainCallback(target.onMouseEnter, () => {
|
||||
viewport?.viewport.updateStatusMouseOnNode(true)
|
||||
viewport?.viewport.refreshViewport()
|
||||
})
|
||||
target.onMouseLeave = useChainCallback(target.onMouseLeave, () => {
|
||||
viewport?.viewport.updateStatusMouseOnNode(false)
|
||||
})
|
||||
}
|
||||
|
||||
function unwireNodeMouseStatus(): void {
|
||||
if (!wiredNode) return
|
||||
wiredNode.onMouseEnter = originalOnMouseEnter
|
||||
wiredNode.onMouseLeave = originalOnMouseLeave
|
||||
wiredNode = null
|
||||
}
|
||||
|
||||
function wireWidgetsToOverlay(target: NodeWithWidgets): void {
|
||||
if (!target.widgets) return
|
||||
for (const name of WIDGET_NAMES) {
|
||||
const widget = target.widgets.find(
|
||||
(w): w is MutableWidget => w.name === name
|
||||
)
|
||||
if (!widget || wrappedSet.has(widget)) continue
|
||||
wrappedSet.add(widget)
|
||||
const original = widget.callback
|
||||
const isModeWidget = widget.name === 'mode'
|
||||
wrappedWidgets.push({ widget, original })
|
||||
widget.callback = (value, ...rest) => {
|
||||
original?.call(widget, value, ...rest)
|
||||
if (isModeWidget) wireWidgetsToOverlay(target)
|
||||
if (!viewport) return
|
||||
const state = readStateFromWidgets(target)
|
||||
mode.value = state.mode
|
||||
viewport.applyState(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unwireWidgets(): void {
|
||||
for (const { widget, original } of wrappedWidgets) {
|
||||
widget.callback = original
|
||||
wrappedSet.delete(widget)
|
||||
}
|
||||
wrappedWidgets.length = 0
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
cleanup,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setGizmosVisible,
|
||||
setTransformGizmoMode,
|
||||
setLookThrough,
|
||||
mode
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/composables/useFeatureFlags'
|
||||
import * as distributionTypes from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedBillingControlEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
|
||||
localStorage.setItem('ff:billing_control_enabled', 'true')
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
|
||||
it('billingControlEnabled is false off-cloud even without an override', () => {
|
||||
vi.mocked(distributionTypes).isCloud = false
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
|
||||
remoteConfigState.value = 'unloaded'
|
||||
remoteConfig.value = {}
|
||||
cachedTeamWorkspacesEnabled.value = undefined
|
||||
cachedConsolidatedBillingEnabled.value = undefined
|
||||
cachedBillingControlEnabled.value = undefined
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns the cached session value during the auth window', () => {
|
||||
cachedTeamWorkspacesEnabled.value = false
|
||||
cachedConsolidatedBillingEnabled.value = true
|
||||
cachedBillingControlEnabled.value = true
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false during the auth window when nothing is cached', () => {
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(false)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(false)
|
||||
expect(flags.billingControlEnabled).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers authenticated remoteConfig over the server feature fallback', () => {
|
||||
remoteConfigState.value = 'authenticated'
|
||||
remoteConfig.value = {
|
||||
team_workspaces_enabled: true,
|
||||
consolidated_billing_enabled: true
|
||||
billing_control_enabled: true
|
||||
}
|
||||
vi.mocked(api.getServerFeature).mockReturnValue(false)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
|
||||
@@ -295,15 +295,14 @@ describe('useFeatureFlags', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(path, defaultValue) => {
|
||||
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
|
||||
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
|
||||
return true
|
||||
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
|
||||
return defaultValue
|
||||
}
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.teamWorkspacesEnabled).toBe(true)
|
||||
expect(flags.consolidatedBillingEnabled).toBe(true)
|
||||
expect(flags.billingControlEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedConsolidatedBillingEnabled,
|
||||
cachedBillingControlEnabled,
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
@@ -32,7 +32,8 @@ export enum ServerFeatureFlag {
|
||||
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
|
||||
SHOW_SIGNIN_BUTTON = 'show_signin_button',
|
||||
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
|
||||
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
|
||||
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
|
||||
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
|
||||
SIGNUP_TURNSTILE = 'signup_turnstile'
|
||||
}
|
||||
|
||||
@@ -191,15 +192,25 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
/**
|
||||
* Whether personal workspaces use the consolidated (workspace-scoped)
|
||||
* billing flow. While false (default), personal workspaces stay on the
|
||||
* legacy per-user billing flow; team workspaces are unaffected.
|
||||
* Whether personal workspaces use the workspace-scoped billing flow. While
|
||||
* false (default), personal workspaces stay on the legacy per-user billing
|
||||
* flow; team workspaces are unaffected.
|
||||
*/
|
||||
get consolidatedBillingEnabled() {
|
||||
get billingControlEnabled() {
|
||||
return resolveAuthGatedFlag(
|
||||
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
|
||||
remoteConfig.value.consolidated_billing_enabled,
|
||||
cachedConsolidatedBillingEnabled
|
||||
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
|
||||
remoteConfig.value.billing_control_enabled,
|
||||
cachedBillingControlEnabled
|
||||
)
|
||||
},
|
||||
get freeTierJobAllowanceEnabled() {
|
||||
const config = remoteConfig.value as typeof remoteConfig.value & {
|
||||
free_tier_job_allowance_enabled?: boolean
|
||||
}
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
|
||||
config.free_tier_job_allowance_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get signupTurnstileMode() {
|
||||
|
||||
@@ -143,7 +143,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
fov: 75,
|
||||
hasCustomUp: false,
|
||||
useCustomUp: false
|
||||
})
|
||||
|
||||
const lightConfig = ref<LightConfig>({
|
||||
@@ -534,6 +536,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
if (newValue.hasCustomUp) {
|
||||
load3d.setUseCustomUp(newValue.useCustomUp ?? false)
|
||||
}
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
@@ -871,6 +876,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
cameraTypeChange: (value: string) => {
|
||||
cameraConfig.value.cameraType = value as CameraType
|
||||
},
|
||||
cameraUpStateChange: (value: {
|
||||
hasCustomUp: boolean
|
||||
usingCustomUp: boolean
|
||||
}) => {
|
||||
cameraConfig.value.hasCustomUp = value.hasCustomUp
|
||||
cameraConfig.value.useCustomUp = value.usingCustomUp
|
||||
},
|
||||
showGridChange: (value: boolean) => {
|
||||
sceneConfig.value.showGrid = value
|
||||
},
|
||||
|
||||
40
src/extensions/core/cameraInfo.ts
Normal file
40
src/extensions/core/cameraInfo.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import CameraInfo from '@/components/cameraInfo/CameraInfo.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateCameraInfo',
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
CAMERA_INFO_STATE(node) {
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: 'camera_info_state',
|
||||
component: CameraInfo,
|
||||
inputSpec: {
|
||||
name: 'camera_info_state',
|
||||
type: 'CAMERA_INFO_STATE',
|
||||
isPreview: false
|
||||
},
|
||||
options: {}
|
||||
})
|
||||
widget.type = 'cameraInfo'
|
||||
addWidget(node, widget)
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'CreateCameraInfo') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 360), Math.max(oldHeight, 480)])
|
||||
|
||||
await nextTick()
|
||||
}
|
||||
})
|
||||
145
src/extensions/core/cameraInfo/CameraInfoOverlay.test.ts
Normal file
145
src/extensions/core/cameraInfo/CameraInfoOverlay.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { CameraInfoOverlay } from './CameraInfoOverlay'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import type { CameraInfoState } from './types'
|
||||
|
||||
function setupOverlay(initial?: Partial<CameraInfoState>) {
|
||||
const overlay = new CameraInfoOverlay({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
...initial
|
||||
})
|
||||
const scene = new THREE.Scene()
|
||||
overlay.attach(scene)
|
||||
return { overlay, scene }
|
||||
}
|
||||
|
||||
function findByName(scene: THREE.Scene, name: string): THREE.Object3D | null {
|
||||
for (const child of scene.children) {
|
||||
if (child.name === name) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('CameraInfoOverlay', () => {
|
||||
let ctx: ReturnType<typeof setupOverlay>
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = setupOverlay()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ctx.overlay.dispose()
|
||||
})
|
||||
|
||||
describe('attach / detach', () => {
|
||||
it('adds reference + subject cameras + camera helper to the scene', () => {
|
||||
expect(findByName(ctx.scene, 'CameraInfoReference')).not.toBeNull()
|
||||
const subjectCameras = ctx.scene.children.filter(
|
||||
(c) =>
|
||||
c instanceof THREE.PerspectiveCamera ||
|
||||
c instanceof THREE.OrthographicCamera
|
||||
)
|
||||
expect(subjectCameras).toHaveLength(2)
|
||||
expect(
|
||||
ctx.scene.children.some((c) => c instanceof THREE.CameraHelper)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('detach removes everything the overlay added', () => {
|
||||
ctx.overlay.detach()
|
||||
|
||||
expect(findByName(ctx.scene, 'CameraInfoReference')).toBeNull()
|
||||
expect(
|
||||
ctx.scene.children.some(
|
||||
(c) =>
|
||||
c instanceof THREE.PerspectiveCamera ||
|
||||
c instanceof THREE.OrthographicCamera ||
|
||||
c instanceof THREE.CameraHelper
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyState — orbit mode', () => {
|
||||
it('positions the active subject camera per yaw / pitch / distance', () => {
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const cam = ctx.overlay.getSubjectCamera()
|
||||
expect(cam.position.x).toBeCloseTo(0)
|
||||
expect(cam.position.y).toBeCloseTo(0)
|
||||
expect(cam.position.z).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('updates fov on the perspective subject camera', () => {
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
fov: 70
|
||||
})
|
||||
|
||||
const cam = ctx.overlay.getSubjectCamera() as THREE.PerspectiveCamera
|
||||
expect(cam.fov).toBe(70)
|
||||
})
|
||||
})
|
||||
|
||||
describe('camera type swap', () => {
|
||||
it('toggling to orthographic swaps the active subject camera and points the helper at it', () => {
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
|
||||
expect(ctx.overlay.getSubjectCamera()).toBeInstanceOf(
|
||||
THREE.OrthographicCamera
|
||||
)
|
||||
const newHelper = ctx.scene.children.find(
|
||||
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
|
||||
)
|
||||
expect(newHelper).toBeDefined()
|
||||
expect(newHelper?.camera).toBe(ctx.overlay.getSubjectCamera())
|
||||
})
|
||||
})
|
||||
|
||||
describe('POV (onActiveCameraChange)', () => {
|
||||
it('hides the camera helper when the render camera IS the subject camera', () => {
|
||||
const subjectCam = ctx.overlay.getSubjectCamera()
|
||||
|
||||
ctx.overlay.onActiveCameraChange(subjectCam)
|
||||
|
||||
const helper = ctx.scene.children.find(
|
||||
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
|
||||
)!
|
||||
expect(helper.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the camera helper when render camera is some other camera', () => {
|
||||
const subjectCam = ctx.overlay.getSubjectCamera()
|
||||
ctx.overlay.onActiveCameraChange(subjectCam)
|
||||
const otherCam = new THREE.PerspectiveCamera()
|
||||
|
||||
ctx.overlay.onActiveCameraChange(otherCam)
|
||||
|
||||
const helper = ctx.scene.children.find(
|
||||
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
|
||||
)!
|
||||
expect(helper.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getState immutability', () => {
|
||||
it('returns a deep clone the caller cannot mutate to affect overlay', () => {
|
||||
const snapshot = ctx.overlay.getState()
|
||||
snapshot.orbit.yaw = 999
|
||||
|
||||
const next = ctx.overlay.getState()
|
||||
expect(next.orbit.yaw).not.toBe(999)
|
||||
expect(next.target).not.toBe(snapshot.target)
|
||||
})
|
||||
})
|
||||
})
|
||||
210
src/extensions/core/cameraInfo/CameraInfoOverlay.ts
Normal file
210
src/extensions/core/cameraInfo/CameraInfoOverlay.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { SceneOverlay } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import type { CameraInfoCameraType, CameraInfoState } from './types'
|
||||
|
||||
const REFERENCE_CUBE_SIZE = 1
|
||||
const SUBJECT_CAMERA_NEAR = 0.1
|
||||
const SUBJECT_CAMERA_FAR = 1000
|
||||
const ORTHO_FRUSTUM_HALF = 1
|
||||
|
||||
export class CameraInfoOverlay implements SceneOverlay {
|
||||
private scene: THREE.Scene | null = null
|
||||
private state: CameraInfoState
|
||||
|
||||
private readonly subjectPerspective: THREE.PerspectiveCamera
|
||||
private readonly subjectOrthographic: THREE.OrthographicCamera
|
||||
private subjectCamera: THREE.Camera
|
||||
private cameraHelper: THREE.CameraHelper | null = null
|
||||
|
||||
private readonly referenceGroup: THREE.Group
|
||||
private readonly referenceCube: THREE.Mesh
|
||||
private readonly referenceCubeEdges: THREE.LineSegments
|
||||
private readonly axesHelper: THREE.AxesHelper
|
||||
|
||||
private renderCamera: THREE.Camera | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE) {
|
||||
this.state = cloneState(initialState)
|
||||
|
||||
this.subjectPerspective = new THREE.PerspectiveCamera(
|
||||
this.state.fov,
|
||||
1,
|
||||
SUBJECT_CAMERA_NEAR,
|
||||
SUBJECT_CAMERA_FAR
|
||||
)
|
||||
this.subjectOrthographic = new THREE.OrthographicCamera(
|
||||
-ORTHO_FRUSTUM_HALF,
|
||||
ORTHO_FRUSTUM_HALF,
|
||||
ORTHO_FRUSTUM_HALF,
|
||||
-ORTHO_FRUSTUM_HALF,
|
||||
SUBJECT_CAMERA_NEAR,
|
||||
SUBJECT_CAMERA_FAR
|
||||
)
|
||||
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
|
||||
|
||||
const cubeGeometry = new THREE.BoxGeometry(
|
||||
REFERENCE_CUBE_SIZE,
|
||||
REFERENCE_CUBE_SIZE,
|
||||
REFERENCE_CUBE_SIZE
|
||||
)
|
||||
this.referenceCube = new THREE.Mesh(
|
||||
cubeGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xb8bcc4,
|
||||
roughness: 0.55,
|
||||
metalness: 0.15
|
||||
})
|
||||
)
|
||||
this.referenceCube.position.set(0, REFERENCE_CUBE_SIZE / 2, 0)
|
||||
|
||||
this.referenceCubeEdges = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(cubeGeometry),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: 0x1a1a1a,
|
||||
transparent: true,
|
||||
opacity: 0.4
|
||||
})
|
||||
)
|
||||
this.referenceCube.add(this.referenceCubeEdges)
|
||||
|
||||
this.axesHelper = new THREE.AxesHelper(REFERENCE_CUBE_SIZE * 1.25)
|
||||
|
||||
this.referenceGroup = new THREE.Group()
|
||||
this.referenceGroup.name = 'CameraInfoReference'
|
||||
this.referenceGroup.add(this.referenceCube)
|
||||
this.referenceGroup.add(this.axesHelper)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.referenceGroup)
|
||||
scene.add(this.subjectPerspective)
|
||||
scene.add(this.subjectOrthographic)
|
||||
this.rebuildCameraHelper()
|
||||
this.applyStateToScene()
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.referenceGroup)
|
||||
this.scene.remove(this.subjectPerspective)
|
||||
this.scene.remove(this.subjectOrthographic)
|
||||
if (this.cameraHelper) this.scene.remove(this.cameraHelper)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
update(_delta: number): void {
|
||||
this.cameraHelper?.update()
|
||||
}
|
||||
|
||||
onActiveCameraChange(camera: THREE.Camera): void {
|
||||
this.renderCamera = camera
|
||||
this.refreshHelperVisibility()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.disposeCameraHelper()
|
||||
this.referenceCube.geometry.dispose()
|
||||
;(this.referenceCube.material as THREE.Material).dispose()
|
||||
this.referenceCubeEdges.geometry.dispose()
|
||||
;(this.referenceCubeEdges.material as THREE.Material).dispose()
|
||||
this.axesHelper.dispose()
|
||||
}
|
||||
|
||||
getSubjectCamera(): THREE.Camera {
|
||||
return this.subjectCamera
|
||||
}
|
||||
|
||||
setHelperVisible(visible: boolean): void {
|
||||
if (this.cameraHelper) this.cameraHelper.visible = visible
|
||||
}
|
||||
|
||||
getState(): CameraInfoState {
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
applyState(next: CameraInfoState): void {
|
||||
const cameraTypeChanged = next.cameraType !== this.state.cameraType
|
||||
const modeChanged = next.mode !== this.state.mode
|
||||
this.state = cloneState(next)
|
||||
if (cameraTypeChanged) {
|
||||
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
|
||||
this.rebuildCameraHelper()
|
||||
}
|
||||
this.applyStateToScene()
|
||||
if (modeChanged || cameraTypeChanged) this.refreshHelperVisibility()
|
||||
}
|
||||
|
||||
private applyStateToScene(): void {
|
||||
const { position, quaternion } = computeSubjectTransform(this.state)
|
||||
this.subjectCamera.position.copy(position)
|
||||
this.subjectCamera.quaternion.copy(quaternion)
|
||||
this.subjectCamera.updateMatrixWorld(true)
|
||||
|
||||
if (this.subjectCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.subjectCamera.fov = this.state.fov
|
||||
this.subjectCamera.zoom = this.state.zoom
|
||||
this.subjectCamera.updateProjectionMatrix()
|
||||
} else if (this.subjectCamera instanceof THREE.OrthographicCamera) {
|
||||
this.subjectCamera.zoom = this.state.zoom
|
||||
this.subjectCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.cameraHelper?.update()
|
||||
}
|
||||
|
||||
private subjectCameraFor(type: CameraInfoCameraType): THREE.Camera {
|
||||
return type === 'perspective'
|
||||
? this.subjectPerspective
|
||||
: this.subjectOrthographic
|
||||
}
|
||||
|
||||
private rebuildCameraHelper(): void {
|
||||
this.disposeCameraHelper()
|
||||
if (!this.scene) return
|
||||
this.cameraHelper = new THREE.CameraHelper(this.subjectCamera)
|
||||
this.scene.add(this.cameraHelper)
|
||||
this.refreshHelperVisibility()
|
||||
}
|
||||
|
||||
private disposeCameraHelper(): void {
|
||||
if (!this.cameraHelper) return
|
||||
if (this.scene) this.scene.remove(this.cameraHelper)
|
||||
this.cameraHelper.geometry.dispose()
|
||||
const material = this.cameraHelper.material as
|
||||
| THREE.Material
|
||||
| THREE.Material[]
|
||||
if (Array.isArray(material)) material.forEach((m) => m.dispose())
|
||||
else material.dispose()
|
||||
this.cameraHelper = null
|
||||
}
|
||||
|
||||
private refreshHelperVisibility(): void {
|
||||
if (!this.cameraHelper) return
|
||||
this.cameraHelper.visible = this.renderCamera !== this.subjectCamera
|
||||
}
|
||||
}
|
||||
|
||||
function cloneState(state: CameraInfoState): CameraInfoState {
|
||||
return {
|
||||
mode: state.mode,
|
||||
target: { ...state.target },
|
||||
roll: state.roll,
|
||||
fov: state.fov,
|
||||
zoom: state.zoom,
|
||||
cameraType: state.cameraType,
|
||||
orbit: { ...state.orbit },
|
||||
lookAt: { position: { ...state.lookAt.position } },
|
||||
quaternion: {
|
||||
position: { ...state.quaternion.position },
|
||||
quat: { ...state.quaternion.quat }
|
||||
}
|
||||
}
|
||||
}
|
||||
427
src/extensions/core/cameraInfo/CameraInfoViewport.test.ts
Normal file
427
src/extensions/core/cameraInfo/CameraInfoViewport.test.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createViewport3dMock } = vi.hoisted(() => ({
|
||||
createViewport3dMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/createViewport3d', () => ({
|
||||
createViewport3d: createViewport3dMock
|
||||
}))
|
||||
|
||||
import { CameraInfoViewport } from './CameraInfoViewport'
|
||||
|
||||
function makeViewportStub() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 600
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
canvas.setPointerCapture = vi.fn()
|
||||
canvas.releasePointerCapture = vi.fn()
|
||||
canvas.hasPointerCapture = vi.fn(() => false)
|
||||
let preRender: (() => void) | null = null
|
||||
let postRender: (() => void) | null = null
|
||||
return {
|
||||
sceneManager: { scene: new THREE.Scene() },
|
||||
cameraManager: { activeCamera: new THREE.PerspectiveCamera() },
|
||||
controlsManager: { controls: { enabled: true } },
|
||||
viewHelperManager: { visibleViewHelper: vi.fn() },
|
||||
renderer: {
|
||||
setViewport: vi.fn(),
|
||||
setScissor: vi.fn(),
|
||||
setScissorTest: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn()
|
||||
},
|
||||
domElement: canvas,
|
||||
setOverlay: vi.fn(),
|
||||
addPreRenderCallback: vi.fn((cb: () => void) => {
|
||||
preRender = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
addPostRenderCallback: vi.fn((cb: () => void) => {
|
||||
postRender = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
setExternalActiveCamera: vi.fn(),
|
||||
forceRender: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
runPreRender: () => preRender?.(),
|
||||
runPostRender: () => postRender?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('CameraInfoViewport look-through', () => {
|
||||
let stub: ReturnType<typeof makeViewportStub>
|
||||
let viewport: CameraInfoViewport
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('renders the main viewport through the subject camera', () => {
|
||||
viewport.setLookThrough(true)
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
|
||||
viewport.overlay.getSubjectCamera()
|
||||
)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the orbit view and keeps the gnomon hidden on exit', () => {
|
||||
viewport.setLookThrough(true)
|
||||
stub.viewHelperManager.visibleViewHelper.mockClear()
|
||||
|
||||
viewport.setLookThrough(false)
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenLastCalledWith(null)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(true)
|
||||
expect(stub.viewHelperManager.visibleViewHelper).toHaveBeenLastCalledWith(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent for repeated toggles', () => {
|
||||
viewport.setLookThrough(true)
|
||||
viewport.setLookThrough(true)
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-syncs the external camera when the camera type changes while looking through', () => {
|
||||
viewport.setLookThrough(true)
|
||||
stub.setExternalActiveCamera.mockClear()
|
||||
|
||||
const state = viewport.overlay.getState()
|
||||
viewport.applyState({ ...state, cameraType: 'orthographic' })
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
|
||||
viewport.overlay.getSubjectCamera()
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the external camera untouched on state changes outside look-through', () => {
|
||||
viewport.applyState(viewport.overlay.getState())
|
||||
|
||||
expect(stub.setExternalActiveCamera).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps handles hidden while looking through even when gizmos are toggled', () => {
|
||||
viewport.setLookThrough(true)
|
||||
viewport.setGizmosVisible(false)
|
||||
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('fits the subject camera aspect in the pre-render pass while looking through', () => {
|
||||
viewport.setLookThrough(true)
|
||||
const cam = viewport.overlay.getSubjectCamera() as THREE.PerspectiveCamera
|
||||
cam.aspect = 1
|
||||
cam.updateProjectionMatrix()
|
||||
|
||||
stub.runPreRender()
|
||||
|
||||
expect(cam.aspect).toBeCloseTo(800 / 600)
|
||||
})
|
||||
|
||||
it('leaves the subject camera aspect untouched outside look-through', () => {
|
||||
const cam = viewport.overlay.getSubjectCamera() as THREE.PerspectiveCamera
|
||||
cam.aspect = 1
|
||||
cam.updateProjectionMatrix()
|
||||
|
||||
stub.runPreRender()
|
||||
|
||||
expect(cam.aspect).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraInfoViewport gizmo visibility', () => {
|
||||
let stub: ReturnType<typeof makeViewportStub>
|
||||
let viewport: CameraInfoViewport
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('hides orbit handles when gizmos are turned off', () => {
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(true)
|
||||
|
||||
viewport.setGizmosVisible(false)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(false)
|
||||
|
||||
viewport.setGizmosVisible(true)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('reveals the target handle in target transform mode', () => {
|
||||
viewport.setTransformGizmoMode('target')
|
||||
|
||||
expect(viewport.targetHandle.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the camera handle for camera-rotate in quaternion mode', () => {
|
||||
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
|
||||
viewport.setTransformGizmoMode('camera-rotate')
|
||||
|
||||
expect(viewport.cameraHandle.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the transform handles when gizmos are turned off', () => {
|
||||
viewport.setTransformGizmoMode('target')
|
||||
expect(viewport.targetHandle.isVisible()).toBe(true)
|
||||
|
||||
viewport.setGizmosVisible(false)
|
||||
expect(viewport.targetHandle.isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraInfoViewport subject preview', () => {
|
||||
let stub: ReturnType<typeof makeViewportStub>
|
||||
let viewport: CameraInfoViewport
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('renders the corner preview after each frame in the editing view', () => {
|
||||
stub.runPostRender()
|
||||
|
||||
expect(stub.renderer.render).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the preview for an orthographic subject camera too', () => {
|
||||
viewport.applyState({
|
||||
...viewport.overlay.getState(),
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
stub.renderer.render.mockClear()
|
||||
|
||||
stub.runPostRender()
|
||||
|
||||
expect(stub.renderer.render).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips the corner preview while looking through', () => {
|
||||
viewport.setLookThrough(true)
|
||||
stub.renderer.render.mockClear()
|
||||
|
||||
stub.runPostRender()
|
||||
|
||||
expect(stub.renderer.render).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function dispatchPointer(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): void {
|
||||
const event = new MouseEvent(type, { clientX, clientY, button: 0 })
|
||||
Object.defineProperty(event, 'pointerId', { value: 1 })
|
||||
target.dispatchEvent(event)
|
||||
}
|
||||
|
||||
describe('CameraInfoViewport look-through drag', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rotates the subject on left-drag while looking through', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
viewport.setLookThrough(true)
|
||||
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
|
||||
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith('mode.yaw', expect.any(Number))
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('dollies the subject on wheel while looking through', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
viewport.setLookThrough(true)
|
||||
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith(
|
||||
'mode.distance',
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('ignores the wheel when not looking through', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
|
||||
|
||||
expect(onHandleDrag).not.toHaveBeenCalled()
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('does not rotate on drag once look-through is exited', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
viewport.setLookThrough(true)
|
||||
viewport.setLookThrough(false)
|
||||
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
|
||||
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
|
||||
|
||||
expect(onHandleDrag).not.toHaveBeenCalled()
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraInfoViewport gizmo drag callbacks', () => {
|
||||
function controlsOf(handle: unknown): {
|
||||
dispatchEvent(e: { type: string }): void
|
||||
} {
|
||||
return (
|
||||
handle as { controls: { dispatchEvent(e: { type: string }): void } }
|
||||
).controls
|
||||
}
|
||||
|
||||
it('writes camera position fields when the camera handle changes', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
|
||||
|
||||
controlsOf(viewport.cameraHandle).dispatchEvent({ type: 'objectChange' })
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith(
|
||||
'mode.position_x',
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('writes quaternion fields when the camera handle rotates', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
|
||||
viewport.setTransformGizmoMode('camera-rotate')
|
||||
|
||||
controlsOf(viewport.cameraHandle).dispatchEvent({ type: 'objectChange' })
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith('mode.quat_w', expect.any(Number))
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('writes target fields when the target handle changes', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
controlsOf(viewport.targetHandle).dispatchEvent({ type: 'objectChange' })
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith('target_x', expect.any(Number))
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('toggles orbit controls while a handle drag is active', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
|
||||
controlsOf(viewport.targetHandle).dispatchEvent({
|
||||
type: 'dragging-changed',
|
||||
value: true
|
||||
} as { type: string })
|
||||
expect(stub.controlsManager.controls.enabled).toBe(false)
|
||||
|
||||
controlsOf(viewport.targetHandle).dispatchEvent({
|
||||
type: 'dragging-changed',
|
||||
value: false
|
||||
} as { type: string })
|
||||
expect(stub.controlsManager.controls.enabled).toBe(true)
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
})
|
||||
700
src/extensions/core/cameraInfo/CameraInfoViewport.ts
Normal file
700
src/extensions/core/cameraInfo/CameraInfoViewport.ts
Normal file
@@ -0,0 +1,700 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
|
||||
import { createViewport3d } from '@/extensions/core/load3d/createViewport3d'
|
||||
import type { Load3DOptions } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
import { CameraInfoOverlay } from './CameraInfoOverlay'
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import { CameraHandle } from './handles/CameraHandle'
|
||||
import type {
|
||||
CameraHandleMode,
|
||||
CameraHandleTransform
|
||||
} from './handles/CameraHandle'
|
||||
import { OrbitHandles } from './handles/OrbitHandles'
|
||||
import type { OrbitHandleType } from './handles/OrbitHandles'
|
||||
import { RollHandle } from './handles/RollHandle'
|
||||
import { TargetHandle } from './handles/TargetHandle'
|
||||
import { pickHandleAtPointer } from './handles/handlePicking'
|
||||
import {
|
||||
pointToDistance,
|
||||
pointToPitchAngle,
|
||||
pointToYawAngle
|
||||
} from './handles/orbitDragMath'
|
||||
import { pointToRollAngle } from './handles/rollDragMath'
|
||||
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
|
||||
import type { LookThroughResult } from './lookThroughDragMath'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import type {
|
||||
CameraInfoFieldName,
|
||||
CameraInfoMode,
|
||||
CameraInfoState
|
||||
} from './types'
|
||||
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_HEIGHT = 150
|
||||
const PREVIEW_PADDING = 8
|
||||
const PREVIEW_BORDER_COLOR = 0x2a2a2a
|
||||
const LOOK_THROUGH_SENSITIVITY = 0.005
|
||||
|
||||
type DragHandleType = OrbitHandleType | 'roll'
|
||||
|
||||
export type TransformGizmoMode =
|
||||
| 'none'
|
||||
| 'target'
|
||||
| 'camera-translate'
|
||||
| 'camera-rotate'
|
||||
|
||||
const FIELD_NAME_FOR: Record<OrbitHandleType, CameraInfoFieldName> = {
|
||||
yaw: 'mode.yaw',
|
||||
pitch: 'mode.pitch',
|
||||
distance: 'mode.distance'
|
||||
}
|
||||
|
||||
export interface CameraInfoViewportOptions extends Load3DOptions {
|
||||
onHandleDrag?: (fieldName: CameraInfoFieldName, value: number) => void
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
type: DragHandleType
|
||||
pointerId: number
|
||||
}
|
||||
|
||||
export class CameraInfoViewport {
|
||||
readonly viewport: Viewport3d
|
||||
readonly overlay: CameraInfoOverlay
|
||||
readonly orbitHandles: OrbitHandles
|
||||
readonly rollHandle: RollHandle
|
||||
readonly targetHandle: TargetHandle
|
||||
readonly cameraHandle: CameraHandle
|
||||
|
||||
private readonly disposePreRender: () => void
|
||||
private readonly disposePostRender: () => void
|
||||
private readonly onHandleDrag?: CameraInfoViewportOptions['onHandleDrag']
|
||||
private readonly raycaster = new THREE.Raycaster()
|
||||
private readonly pointer = new THREE.Vector2()
|
||||
|
||||
private dragState: DragState | null = null
|
||||
private lookThroughDrag: {
|
||||
pointerId: number
|
||||
lastX: number
|
||||
lastY: number
|
||||
} | null = null
|
||||
private pendingRotation: { dx: number; dy: number } | null = null
|
||||
private pendingDolly: number | null = null
|
||||
private inputFrame: number | null = null
|
||||
private hoveredHandle: DragHandleType | null = null
|
||||
private gizmosOn = true
|
||||
private lookingThrough = false
|
||||
private transformGizmoMode: TransformGizmoMode = 'none'
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE,
|
||||
options?: CameraInfoViewportOptions
|
||||
) {
|
||||
this.onHandleDrag = options?.onHandleDrag
|
||||
this.viewport = createViewport3d(container, options)
|
||||
this.viewport.viewHelperManager.visibleViewHelper(false)
|
||||
this.overlay = new CameraInfoOverlay(initialState)
|
||||
this.viewport.setOverlay(this.overlay)
|
||||
|
||||
this.orbitHandles = new OrbitHandles()
|
||||
this.orbitHandles.attach(this.viewport.sceneManager.scene)
|
||||
this.orbitHandles.update(initialState)
|
||||
|
||||
this.rollHandle = new RollHandle()
|
||||
this.rollHandle.attach(this.viewport.sceneManager.scene)
|
||||
this.rollHandle.update(initialState)
|
||||
|
||||
this.targetHandle = new TargetHandle(
|
||||
this.viewport.cameraManager.activeCamera,
|
||||
this.viewport.domElement,
|
||||
(dragging) => {
|
||||
this.viewport.controlsManager.controls.enabled = !dragging
|
||||
},
|
||||
(target) => {
|
||||
const next: CameraInfoState = { ...this.overlay.getState(), target }
|
||||
this.applyDerivedState(next)
|
||||
this.onHandleDrag?.('target_x', target.x)
|
||||
this.onHandleDrag?.('target_y', target.y)
|
||||
this.onHandleDrag?.('target_z', target.z)
|
||||
}
|
||||
)
|
||||
this.targetHandle.attach(this.viewport.sceneManager.scene)
|
||||
this.targetHandle.setTarget(initialState.target)
|
||||
|
||||
this.cameraHandle = new CameraHandle(
|
||||
this.viewport.cameraManager.activeCamera,
|
||||
this.viewport.domElement,
|
||||
(dragging) => {
|
||||
this.viewport.controlsManager.controls.enabled = !dragging
|
||||
},
|
||||
(transform, mode) => this.handleCameraDrag(transform, mode)
|
||||
)
|
||||
this.cameraHandle.attach(this.viewport.sceneManager.scene)
|
||||
this.syncCameraHandleSubject(initialState)
|
||||
|
||||
this.attachPointerHandlers()
|
||||
|
||||
this.disposePreRender = this.viewport.addPreRenderCallback(() => {
|
||||
if (this.lookingThrough) this.fitSubjectAspect()
|
||||
})
|
||||
this.disposePostRender = this.viewport.addPostRenderCallback(() => {
|
||||
if (!this.lookingThrough) this.renderSubjectCameraPreview()
|
||||
})
|
||||
}
|
||||
|
||||
applyState(state: CameraInfoState): void {
|
||||
this.overlay.applyState(state)
|
||||
this.orbitHandles.update(state)
|
||||
this.rollHandle.update(state)
|
||||
this.targetHandle.setTarget(state.target)
|
||||
this.syncCameraHandleSubject(state)
|
||||
this.refreshGizmoVisibility()
|
||||
if (this.lookingThrough) {
|
||||
this.viewport.setExternalActiveCamera(this.overlay.getSubjectCamera())
|
||||
}
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
setGizmosVisible(on: boolean): void {
|
||||
if (this.gizmosOn === on) return
|
||||
this.gizmosOn = on
|
||||
this.refreshGizmoVisibility()
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
setTransformGizmoMode(mode: TransformGizmoMode): void {
|
||||
if (this.transformGizmoMode === mode) return
|
||||
this.transformGizmoMode = mode
|
||||
this.refreshGizmoVisibility()
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
setLookThrough(on: boolean): void {
|
||||
if (this.lookingThrough === on) return
|
||||
this.lookingThrough = on
|
||||
this.lookThroughDrag = null
|
||||
this.cancelInputFrame()
|
||||
this.canvas.style.cursor = ''
|
||||
this.refreshGizmoVisibility()
|
||||
if (on) this.fitSubjectAspect()
|
||||
this.viewport.setExternalActiveCamera(
|
||||
on ? this.overlay.getSubjectCamera() : null
|
||||
)
|
||||
if (!on) this.viewport.viewHelperManager.visibleViewHelper(false)
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
this.detachPointerHandlers()
|
||||
this.cancelInputFrame()
|
||||
this.canvas.style.cursor = ''
|
||||
this.disposePreRender()
|
||||
this.disposePostRender()
|
||||
this.orbitHandles.dispose()
|
||||
this.rollHandle.dispose()
|
||||
this.targetHandle.dispose()
|
||||
this.cameraHandle.dispose()
|
||||
this.viewport.remove()
|
||||
}
|
||||
|
||||
private refreshGizmoVisibility(): void {
|
||||
if (this.lookingThrough) {
|
||||
this.orbitHandles.setVisible(false)
|
||||
this.rollHandle.setVisible(false)
|
||||
this.targetHandle.setVisible(false)
|
||||
this.cameraHandle.setVisible(false)
|
||||
return
|
||||
}
|
||||
const mode = this.overlay.getState().mode
|
||||
this.orbitHandles.setVisible(this.gizmosOn && mode === 'orbit')
|
||||
this.rollHandle.setVisible(this.gizmosOn && rollApplies(mode))
|
||||
|
||||
const wantTarget =
|
||||
this.gizmosOn &&
|
||||
this.transformGizmoMode === 'target' &&
|
||||
targetApplies(mode)
|
||||
this.targetHandle.setVisible(wantTarget)
|
||||
|
||||
const wantTranslate =
|
||||
this.gizmosOn &&
|
||||
this.transformGizmoMode === 'camera-translate' &&
|
||||
cameraTranslateApplies(mode)
|
||||
const wantRotate =
|
||||
this.gizmosOn &&
|
||||
this.transformGizmoMode === 'camera-rotate' &&
|
||||
cameraRotateApplies(mode)
|
||||
const wantCamera = wantTranslate || wantRotate
|
||||
if (wantCamera)
|
||||
this.cameraHandle.setMode(wantRotate ? 'rotate' : 'translate')
|
||||
this.cameraHandle.setVisible(wantCamera)
|
||||
}
|
||||
|
||||
private syncCameraHandleSubject(state: CameraInfoState): void {
|
||||
const { position, quaternion } = computeSubjectTransform(state)
|
||||
this.cameraHandle.setSubject(
|
||||
{ x: position.x, y: position.y, z: position.z },
|
||||
{ x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }
|
||||
)
|
||||
}
|
||||
|
||||
private applyDerivedState(next: CameraInfoState): void {
|
||||
this.overlay.applyState(next)
|
||||
this.orbitHandles.update(next)
|
||||
this.rollHandle.update(next)
|
||||
this.targetHandle.setTarget(next.target)
|
||||
this.syncCameraHandleSubject(next)
|
||||
this.refreshGizmoVisibility()
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
private handleCameraDrag(
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
): void {
|
||||
const state = this.overlay.getState()
|
||||
const next = nextStateForCameraDrag(state, transform, mode)
|
||||
this.applyDerivedState(next)
|
||||
if (mode === 'translate') {
|
||||
this.onHandleDrag?.('mode.position_x', transform.position.x)
|
||||
this.onHandleDrag?.('mode.position_y', transform.position.y)
|
||||
this.onHandleDrag?.('mode.position_z', transform.position.z)
|
||||
} else {
|
||||
this.onHandleDrag?.('mode.quat_x', transform.quaternion.x)
|
||||
this.onHandleDrag?.('mode.quat_y', transform.quaternion.y)
|
||||
this.onHandleDrag?.('mode.quat_z', transform.quaternion.z)
|
||||
this.onHandleDrag?.('mode.quat_w', transform.quaternion.w)
|
||||
}
|
||||
}
|
||||
|
||||
private get canvas(): HTMLCanvasElement {
|
||||
return this.viewport.domElement
|
||||
}
|
||||
|
||||
private attachPointerHandlers(): void {
|
||||
const canvas = this.canvas
|
||||
canvas.addEventListener('pointerdown', this.onPointerDown)
|
||||
canvas.addEventListener('pointermove', this.onPointerMove)
|
||||
canvas.addEventListener('pointerup', this.onPointerUp)
|
||||
canvas.addEventListener('pointercancel', this.onPointerUp)
|
||||
canvas.addEventListener('pointerleave', this.onPointerLeave)
|
||||
canvas.addEventListener('wheel', this.onWheel, { passive: false })
|
||||
}
|
||||
|
||||
private detachPointerHandlers(): void {
|
||||
const canvas = this.canvas
|
||||
canvas.removeEventListener('pointerdown', this.onPointerDown)
|
||||
canvas.removeEventListener('pointermove', this.onPointerMove)
|
||||
canvas.removeEventListener('pointerup', this.onPointerUp)
|
||||
canvas.removeEventListener('pointercancel', this.onPointerUp)
|
||||
canvas.removeEventListener('pointerleave', this.onPointerLeave)
|
||||
canvas.removeEventListener('wheel', this.onWheel)
|
||||
}
|
||||
|
||||
private readonly onPointerLeave = (): void => {
|
||||
if (this.dragState || this.lookThroughDrag) return
|
||||
this.setHoveredHandle(null)
|
||||
}
|
||||
|
||||
private readonly onWheel = (event: WheelEvent): void => {
|
||||
if (!this.lookingThrough) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.queueDolly(event.deltaY)
|
||||
}
|
||||
|
||||
private updatePointer(event: PointerEvent): void {
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
}
|
||||
|
||||
private pickableTargetsFor(mode: CameraInfoMode): THREE.Object3D[] {
|
||||
const targets: THREE.Object3D[] = []
|
||||
if (mode === 'orbit') {
|
||||
targets.push(...this.orbitHandles.pickableMeshes())
|
||||
}
|
||||
if (rollApplies(mode)) {
|
||||
targets.push(...this.rollHandle.pickableMeshes())
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
private pickHandle(event: PointerEvent): DragHandleType | null {
|
||||
if (!this.gizmosOn || this.lookingThrough) return null
|
||||
const targets = this.pickableTargetsFor(this.overlay.getState().mode)
|
||||
if (targets.length === 0) return null
|
||||
this.updatePointer(event)
|
||||
return pickHandleAtPointer<DragHandleType>(
|
||||
this.raycaster,
|
||||
this.pointer,
|
||||
this.viewport.cameraManager.activeCamera,
|
||||
targets,
|
||||
this.canvas
|
||||
)
|
||||
}
|
||||
|
||||
private setHoveredHandle(type: DragHandleType | null): void {
|
||||
if (this.hoveredHandle === type) return
|
||||
this.hoveredHandle = type
|
||||
this.orbitHandles.setHovered(type === 'roll' ? null : type)
|
||||
this.rollHandle.setHovered(type === 'roll')
|
||||
this.canvas.style.cursor = type ? 'grab' : ''
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0) return
|
||||
|
||||
if (this.lookingThrough) {
|
||||
this.lookThroughDrag = {
|
||||
pointerId: event.pointerId,
|
||||
lastX: event.clientX,
|
||||
lastY: event.clientY
|
||||
}
|
||||
this.canvas.setPointerCapture(event.pointerId)
|
||||
this.canvas.style.cursor = 'grabbing'
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
const type = this.pickHandle(event)
|
||||
if (!type) return
|
||||
|
||||
this.setHoveredHandle(type)
|
||||
this.dragState = { type, pointerId: event.pointerId }
|
||||
this.canvas.setPointerCapture(event.pointerId)
|
||||
this.canvas.style.cursor = 'grabbing'
|
||||
this.viewport.controlsManager.controls.enabled = false
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent): void => {
|
||||
if (this.lookThroughDrag) {
|
||||
if (event.pointerId !== this.lookThroughDrag.pointerId) return
|
||||
const dx = event.clientX - this.lookThroughDrag.lastX
|
||||
const dy = event.clientY - this.lookThroughDrag.lastY
|
||||
this.lookThroughDrag.lastX = event.clientX
|
||||
this.lookThroughDrag.lastY = event.clientY
|
||||
this.queueRotation(dx, dy)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.dragState) {
|
||||
this.setHoveredHandle(this.pickHandle(event))
|
||||
return
|
||||
}
|
||||
if (event.pointerId !== this.dragState.pointerId) return
|
||||
|
||||
this.updatePointer(event)
|
||||
this.raycaster.setFromCamera(
|
||||
this.pointer,
|
||||
this.viewport.cameraManager.activeCamera
|
||||
)
|
||||
|
||||
const state = this.overlay.getState()
|
||||
const plane =
|
||||
this.dragState.type === 'roll'
|
||||
? this.rollHandle.dragPlane(state)
|
||||
: this.orbitHandles.dragPlaneFor(this.dragState.type, state)
|
||||
const point = new THREE.Vector3()
|
||||
if (!this.raycaster.ray.intersectPlane(plane, point)) return
|
||||
|
||||
const { fieldName, value, nextState } = computeNextState(
|
||||
this.dragState.type,
|
||||
state,
|
||||
point
|
||||
)
|
||||
this.applyState(nextState)
|
||||
this.onHandleDrag?.(fieldName, value)
|
||||
}
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent): void => {
|
||||
if (this.lookThroughDrag) {
|
||||
if (event.pointerId !== this.lookThroughDrag.pointerId) return
|
||||
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||
this.canvas.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
this.lookThroughDrag = null
|
||||
this.flushInput()
|
||||
this.cancelInputFrame()
|
||||
this.canvas.style.cursor = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.dragState || event.pointerId !== this.dragState.pointerId) return
|
||||
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||
this.canvas.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
this.dragState = null
|
||||
this.viewport.controlsManager.controls.enabled = true
|
||||
this.canvas.style.cursor = this.hoveredHandle ? 'grab' : ''
|
||||
}
|
||||
|
||||
private queueRotation(dx: number, dy: number): void {
|
||||
this.pendingRotation = {
|
||||
dx: (this.pendingRotation?.dx ?? 0) + dx,
|
||||
dy: (this.pendingRotation?.dy ?? 0) + dy
|
||||
}
|
||||
this.scheduleInputFrame()
|
||||
}
|
||||
|
||||
private queueDolly(deltaY: number): void {
|
||||
this.pendingDolly = (this.pendingDolly ?? 0) + deltaY
|
||||
this.scheduleInputFrame()
|
||||
}
|
||||
|
||||
private scheduleInputFrame(): void {
|
||||
if (this.inputFrame !== null) return
|
||||
this.inputFrame = requestAnimationFrame(() => {
|
||||
this.inputFrame = null
|
||||
this.flushInput()
|
||||
})
|
||||
}
|
||||
|
||||
private flushInput(): void {
|
||||
const rotation = this.pendingRotation
|
||||
const dolly = this.pendingDolly
|
||||
this.pendingRotation = null
|
||||
this.pendingDolly = null
|
||||
if (rotation) {
|
||||
this.applyResult(
|
||||
rotateSubjectByDrag(
|
||||
this.overlay.getState(),
|
||||
-rotation.dx * LOOK_THROUGH_SENSITIVITY,
|
||||
-rotation.dy * LOOK_THROUGH_SENSITIVITY
|
||||
)
|
||||
)
|
||||
}
|
||||
if (dolly !== null) {
|
||||
this.applyResult(dollySubjectByWheel(this.overlay.getState(), dolly))
|
||||
}
|
||||
}
|
||||
|
||||
private applyResult(result: LookThroughResult | null): void {
|
||||
if (!result) return
|
||||
this.applyState(result.nextState)
|
||||
for (const update of result.updates) {
|
||||
this.onHandleDrag?.(update.fieldName, update.value)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelInputFrame(): void {
|
||||
if (this.inputFrame !== null) {
|
||||
cancelAnimationFrame(this.inputFrame)
|
||||
this.inputFrame = null
|
||||
}
|
||||
this.pendingRotation = null
|
||||
this.pendingDolly = null
|
||||
}
|
||||
|
||||
private fitSubjectAspect(): void {
|
||||
const canvas = this.viewport.domElement
|
||||
const aspect = canvas.width / canvas.height
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) return
|
||||
const cam = this.overlay.getSubjectCamera()
|
||||
if (cam instanceof THREE.PerspectiveCamera) {
|
||||
if (Math.abs(cam.aspect - aspect) < 1e-4) return
|
||||
cam.aspect = aspect
|
||||
cam.updateProjectionMatrix()
|
||||
return
|
||||
}
|
||||
if (cam instanceof THREE.OrthographicCamera) {
|
||||
const half = (cam.top - cam.bottom) / 2 || 1
|
||||
const left = -half * aspect
|
||||
const right = half * aspect
|
||||
if (
|
||||
Math.abs(cam.left - left) < 1e-4 &&
|
||||
Math.abs(cam.right - right) < 1e-4
|
||||
)
|
||||
return
|
||||
cam.left = left
|
||||
cam.right = right
|
||||
cam.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
|
||||
private renderSubjectCameraPreview(): void {
|
||||
const renderer = this.viewport.renderer
|
||||
const canvas = this.viewport.domElement
|
||||
const canvasWidth = canvas.width
|
||||
const canvasHeight = canvas.height
|
||||
if (
|
||||
canvasWidth < PREVIEW_WIDTH + PREVIEW_PADDING * 2 ||
|
||||
canvasHeight < PREVIEW_HEIGHT + PREVIEW_PADDING * 2
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const cam = this.overlay.getSubjectCamera()
|
||||
const aspect = PREVIEW_WIDTH / PREVIEW_HEIGHT
|
||||
let savedAspect: number | undefined
|
||||
let savedOrtho:
|
||||
| {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (cam instanceof THREE.PerspectiveCamera) {
|
||||
savedAspect = cam.aspect
|
||||
cam.aspect = aspect
|
||||
cam.updateProjectionMatrix()
|
||||
} else if (cam instanceof THREE.OrthographicCamera) {
|
||||
savedOrtho = {
|
||||
left: cam.left,
|
||||
right: cam.right,
|
||||
top: cam.top,
|
||||
bottom: cam.bottom
|
||||
}
|
||||
const half = (cam.top - cam.bottom) / 2 || 1
|
||||
cam.left = -half * aspect
|
||||
cam.right = half * aspect
|
||||
cam.top = half
|
||||
cam.bottom = -half
|
||||
cam.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.overlay.setHelperVisible(false)
|
||||
const handlesWereVisible = this.orbitHandles.isVisible()
|
||||
const rollWasVisible = this.rollHandle.isVisible()
|
||||
const targetWasVisible = this.targetHandle.isVisible()
|
||||
const cameraWasVisible = this.cameraHandle.isVisible()
|
||||
this.orbitHandles.setVisible(false)
|
||||
this.rollHandle.setVisible(false)
|
||||
this.targetHandle.setVisible(false)
|
||||
this.cameraHandle.setVisible(false)
|
||||
|
||||
const x = canvasWidth - PREVIEW_WIDTH - PREVIEW_PADDING
|
||||
const y = PREVIEW_PADDING
|
||||
|
||||
renderer.setViewport(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
|
||||
renderer.setScissor(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
|
||||
renderer.setScissorTest(true)
|
||||
renderer.setClearColor(PREVIEW_BORDER_COLOR)
|
||||
renderer.clear()
|
||||
|
||||
renderer.setViewport(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
|
||||
renderer.setScissor(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
|
||||
renderer.setClearColor(0x0a0a0a)
|
||||
renderer.clear()
|
||||
renderer.render(this.viewport.sceneManager.scene, cam)
|
||||
|
||||
this.overlay.setHelperVisible(true)
|
||||
if (handlesWereVisible) this.orbitHandles.setVisible(true)
|
||||
if (rollWasVisible) this.rollHandle.setVisible(true)
|
||||
if (targetWasVisible) this.targetHandle.setVisible(true)
|
||||
if (cameraWasVisible) this.cameraHandle.setVisible(true)
|
||||
|
||||
if (savedAspect !== undefined && cam instanceof THREE.PerspectiveCamera) {
|
||||
cam.aspect = savedAspect
|
||||
cam.updateProjectionMatrix()
|
||||
} else if (savedOrtho && cam instanceof THREE.OrthographicCamera) {
|
||||
cam.left = savedOrtho.left
|
||||
cam.right = savedOrtho.right
|
||||
cam.top = savedOrtho.top
|
||||
cam.bottom = savedOrtho.bottom
|
||||
cam.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OrbitDragResult {
|
||||
fieldName: CameraInfoFieldName
|
||||
value: number
|
||||
nextState: CameraInfoState
|
||||
}
|
||||
|
||||
function computeNextState(
|
||||
type: DragHandleType,
|
||||
state: CameraInfoState,
|
||||
point: THREE.Vector3
|
||||
): OrbitDragResult {
|
||||
if (type === 'roll') {
|
||||
const cameraPos = computeSubjectTransform(state).position
|
||||
const value = pointToRollAngle(
|
||||
{ x: point.x, y: point.y, z: point.z },
|
||||
state.target,
|
||||
{ x: cameraPos.x, y: cameraPos.y, z: cameraPos.z }
|
||||
)
|
||||
return {
|
||||
fieldName: 'roll',
|
||||
value,
|
||||
nextState: { ...state, roll: value }
|
||||
}
|
||||
}
|
||||
const fieldName = FIELD_NAME_FOR[type]
|
||||
if (type === 'yaw') {
|
||||
const value = pointToYawAngle(point, state.target)
|
||||
return {
|
||||
fieldName,
|
||||
value,
|
||||
nextState: { ...state, orbit: { ...state.orbit, yaw: value } }
|
||||
}
|
||||
}
|
||||
if (type === 'pitch') {
|
||||
const value = pointToPitchAngle(point, state.target, state.orbit.yaw)
|
||||
return {
|
||||
fieldName,
|
||||
value,
|
||||
nextState: { ...state, orbit: { ...state.orbit, pitch: value } }
|
||||
}
|
||||
}
|
||||
const value = pointToDistance(
|
||||
point,
|
||||
state.target,
|
||||
state.orbit.yaw,
|
||||
state.orbit.pitch
|
||||
)
|
||||
return {
|
||||
fieldName,
|
||||
value,
|
||||
nextState: { ...state, orbit: { ...state.orbit, distance: value } }
|
||||
}
|
||||
}
|
||||
|
||||
function targetApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'orbit' || mode === 'look_at'
|
||||
}
|
||||
|
||||
function cameraTranslateApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'look_at' || mode === 'quaternion'
|
||||
}
|
||||
|
||||
function cameraRotateApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'quaternion'
|
||||
}
|
||||
|
||||
function rollApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'orbit' || mode === 'look_at'
|
||||
}
|
||||
|
||||
function nextStateForCameraDrag(
|
||||
state: CameraInfoState,
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
): CameraInfoState {
|
||||
const { position, quaternion } = transform
|
||||
if (mode === 'translate') {
|
||||
if (state.mode === 'look_at') {
|
||||
return { ...state, lookAt: { position: { ...position } } }
|
||||
}
|
||||
if (state.mode === 'quaternion') {
|
||||
return {
|
||||
...state,
|
||||
quaternion: { ...state.quaternion, position: { ...position } }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
if (state.mode === 'quaternion') {
|
||||
return {
|
||||
...state,
|
||||
quaternion: { ...state.quaternion, quat: { ...quaternion } }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
162
src/extensions/core/cameraInfo/cameraTransform.test.ts
Normal file
162
src/extensions/core/cameraInfo/cameraTransform.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import type { CameraInfoState } from './types'
|
||||
|
||||
function withMode(
|
||||
mode: CameraInfoState['mode'],
|
||||
overrides: Partial<CameraInfoState> = {}
|
||||
): CameraInfoState {
|
||||
return { ...DEFAULT_CAMERA_INFO_STATE, mode, ...overrides }
|
||||
}
|
||||
|
||||
describe('computeSubjectTransform', () => {
|
||||
describe('orbit mode', () => {
|
||||
it('positions the camera at target + distance along yaw/pitch', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(0)
|
||||
expect(position.y).toBeCloseTo(0)
|
||||
expect(position.z).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('honors yaw=90deg (camera goes to +X)', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 90, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(5)
|
||||
expect(position.y).toBeCloseTo(0)
|
||||
expect(position.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('honors pitch=90deg (camera goes straight up)', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 0, pitch: 90, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(0)
|
||||
expect(position.y).toBeCloseTo(5)
|
||||
expect(position.z).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
it('offsets the orbit by target', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 10, y: 20, z: 30 },
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(10)
|
||||
expect(position.y).toBeCloseTo(20)
|
||||
expect(position.z).toBeCloseTo(35)
|
||||
})
|
||||
})
|
||||
|
||||
describe('look_at mode', () => {
|
||||
it('uses explicit position', () => {
|
||||
const state = withMode('look_at', {
|
||||
lookAt: { position: { x: 1, y: 2, z: 3 } }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.toArray()).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('quaternion mode', () => {
|
||||
it('uses explicit position and quaternion (target ignored)', () => {
|
||||
const state = withMode('quaternion', {
|
||||
target: { x: 999, y: 999, z: 999 },
|
||||
quaternion: {
|
||||
position: { x: 7, y: 8, z: 9 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
const { position, quaternion } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.toArray()).toEqual([7, 8, 9])
|
||||
expect(quaternion.x).toBeCloseTo(0)
|
||||
expect(quaternion.y).toBeCloseTo(0)
|
||||
expect(quaternion.z).toBeCloseTo(0)
|
||||
expect(quaternion.w).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('normalizes a non-unit quaternion', () => {
|
||||
const state = withMode('quaternion', {
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quat: { x: 2, y: 0, z: 0, w: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const { quaternion } = computeSubjectTransform(state)
|
||||
|
||||
expect(quaternion.length()).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('falls back to identity when given a zero quaternion', () => {
|
||||
const state = withMode('quaternion', {
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const { quaternion } = computeSubjectTransform(state)
|
||||
|
||||
expect(quaternion.x).toBe(0)
|
||||
expect(quaternion.y).toBe(0)
|
||||
expect(quaternion.z).toBe(0)
|
||||
expect(quaternion.w).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roll', () => {
|
||||
it('rotates around the view axis without moving the camera position', () => {
|
||||
const base = withMode('look_at', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
roll: 0
|
||||
})
|
||||
const rolled = { ...base, roll: 45 }
|
||||
|
||||
const a = computeSubjectTransform(base)
|
||||
const b = computeSubjectTransform(rolled)
|
||||
|
||||
expect(a.position.toArray()).toEqual(b.position.toArray())
|
||||
|
||||
const forward = new THREE.Vector3(0, 0, -1)
|
||||
expect(
|
||||
forward
|
||||
.clone()
|
||||
.applyQuaternion(a.quaternion)
|
||||
.angleTo(forward.clone().applyQuaternion(b.quaternion))
|
||||
).toBeCloseTo(0)
|
||||
|
||||
const up = new THREE.Vector3(0, 1, 0)
|
||||
expect(
|
||||
up
|
||||
.clone()
|
||||
.applyQuaternion(a.quaternion)
|
||||
.angleTo(up.clone().applyQuaternion(b.quaternion))
|
||||
).toBeCloseTo(Math.PI / 4)
|
||||
})
|
||||
})
|
||||
})
|
||||
89
src/extensions/core/cameraInfo/cameraTransform.ts
Normal file
89
src/extensions/core/cameraInfo/cameraTransform.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { CameraInfoState } from './types'
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
export interface SubjectCameraTransform {
|
||||
position: THREE.Vector3
|
||||
quaternion: THREE.Quaternion
|
||||
}
|
||||
|
||||
function orbitPosition(
|
||||
target: THREE.Vector3Like,
|
||||
yawDeg: number,
|
||||
pitchDeg: number,
|
||||
distance: number
|
||||
): THREE.Vector3 {
|
||||
const y = yawDeg * DEG2RAD
|
||||
const p = pitchDeg * DEG2RAD
|
||||
const cp = Math.cos(p)
|
||||
return new THREE.Vector3(
|
||||
target.x + distance * cp * Math.sin(y),
|
||||
target.y + distance * Math.sin(p),
|
||||
target.z + distance * cp * Math.cos(y)
|
||||
)
|
||||
}
|
||||
|
||||
function lookAtQuaternion(
|
||||
position: THREE.Vector3,
|
||||
target: THREE.Vector3Like,
|
||||
rollDeg: number
|
||||
): THREE.Quaternion {
|
||||
const targetVec = new THREE.Vector3(target.x, target.y, target.z)
|
||||
const m = new THREE.Matrix4().lookAt(
|
||||
position,
|
||||
targetVec,
|
||||
new THREE.Vector3(0, 1, 0)
|
||||
)
|
||||
const q = new THREE.Quaternion().setFromRotationMatrix(m)
|
||||
if (rollDeg !== 0) {
|
||||
const rollQ = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 0, 1),
|
||||
rollDeg * DEG2RAD
|
||||
)
|
||||
q.multiply(rollQ)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
export function normalizeQuaternion(q: THREE.Quaternion): THREE.Quaternion {
|
||||
if (q.lengthSq() === 0) q.set(0, 0, 0, 1)
|
||||
else q.normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
export function computeSubjectTransform(
|
||||
state: CameraInfoState
|
||||
): SubjectCameraTransform {
|
||||
if (state.mode === 'quaternion') {
|
||||
const p = state.quaternion.position
|
||||
const q = state.quaternion.quat
|
||||
const quaternion = normalizeQuaternion(
|
||||
new THREE.Quaternion(q.x, q.y, q.z, q.w)
|
||||
)
|
||||
return {
|
||||
position: new THREE.Vector3(p.x, p.y, p.z),
|
||||
quaternion
|
||||
}
|
||||
}
|
||||
|
||||
const position =
|
||||
state.mode === 'orbit'
|
||||
? orbitPosition(
|
||||
state.target,
|
||||
state.orbit.yaw,
|
||||
state.orbit.pitch,
|
||||
state.orbit.distance
|
||||
)
|
||||
: new THREE.Vector3(
|
||||
state.lookAt.position.x,
|
||||
state.lookAt.position.y,
|
||||
state.lookAt.position.z
|
||||
)
|
||||
|
||||
return {
|
||||
position,
|
||||
quaternion: lookAtQuaternion(position, state.target, state.roll)
|
||||
}
|
||||
}
|
||||
127
src/extensions/core/cameraInfo/handles/CameraHandle.test.ts
Normal file
127
src/extensions/core/cameraInfo/handles/CameraHandle.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as THREE from 'three'
|
||||
import type { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CameraHandle } from './CameraHandle'
|
||||
import type { CameraHandleMode, CameraHandleTransform } from './CameraHandle'
|
||||
|
||||
describe('CameraHandle', () => {
|
||||
let scene: THREE.Scene
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let dom: HTMLElement
|
||||
let onDragging: (dragging: boolean) => void
|
||||
let onDraggingMock: ReturnType<typeof vi.fn>
|
||||
let onChange: (
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
) => void
|
||||
let onChangeMock: ReturnType<typeof vi.fn>
|
||||
let handle: CameraHandle
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
dom = document.createElement('div')
|
||||
onDraggingMock = vi.fn()
|
||||
onDragging = onDraggingMock as unknown as (dragging: boolean) => void
|
||||
onChangeMock = vi.fn()
|
||||
onChange = onChangeMock as unknown as (
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
) => void
|
||||
handle = new CameraHandle(camera, dom, onDragging, onChange)
|
||||
handle.attach(scene)
|
||||
})
|
||||
|
||||
function controls(): TransformControls {
|
||||
return (handle as unknown as { controls: TransformControls }).controls
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('adds a proxy + a helper to the scene on attach', () => {
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
|
||||
).toBeDefined()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts hidden with controls disabled', () => {
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to translate mode', () => {
|
||||
expect(handle.getMode()).toBe('translate')
|
||||
})
|
||||
|
||||
it('setMode switches translate <-> rotate', () => {
|
||||
handle.setMode('rotate')
|
||||
expect(handle.getMode()).toBe('rotate')
|
||||
|
||||
handle.setMode('translate')
|
||||
expect(handle.getMode()).toBe('translate')
|
||||
})
|
||||
|
||||
it('setSubject moves the proxy to the given pose without firing onChange', () => {
|
||||
handle.setSubject({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0, w: 1 })
|
||||
|
||||
const proxy = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoCameraProxy'
|
||||
)!
|
||||
expect(proxy.position.x).toBe(1)
|
||||
expect(proxy.position.y).toBe(2)
|
||||
expect(proxy.position.z).toBe(3)
|
||||
expect(onChangeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setSubject is a no-op when the pose already matches', () => {
|
||||
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
|
||||
onChangeMock.mockClear()
|
||||
|
||||
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
|
||||
|
||||
expect(onChangeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setVisible toggles helper.visible and controls.enabled together', () => {
|
||||
handle.setVisible(true)
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
|
||||
handle.setVisible(false)
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits the proxy transform and active mode on gizmo objectChange', () => {
|
||||
const proxy = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoCameraProxy'
|
||||
)!
|
||||
proxy.position.set(1, 2, 3)
|
||||
|
||||
controls().dispatchEvent({ type: 'objectChange' })
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ position: { x: 1, y: 2, z: 3 } }),
|
||||
'translate'
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards dragging-changed to the dragging listener', () => {
|
||||
controls().dispatchEvent({ type: 'dragging-changed', value: true })
|
||||
|
||||
expect(onDraggingMock).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('dispose removes proxy and helper from the scene', () => {
|
||||
handle.dispose()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
149
src/extensions/core/cameraInfo/handles/CameraHandle.ts
Normal file
149
src/extensions/core/cameraInfo/handles/CameraHandle.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as THREE from 'three'
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
|
||||
import type { DraggingChangeListener } from './types'
|
||||
|
||||
export type CameraHandleMode = 'translate' | 'rotate'
|
||||
|
||||
export interface CameraHandleTransform {
|
||||
position: THREE.Vector3Like
|
||||
quaternion: { x: number; y: number; z: number; w: number }
|
||||
}
|
||||
|
||||
type ChangeListener = (
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
) => void
|
||||
|
||||
export class CameraHandle {
|
||||
private readonly proxy: THREE.Object3D
|
||||
private readonly controls: TransformControls
|
||||
private readonly helper: THREE.Object3D
|
||||
private mode: CameraHandleMode = 'translate'
|
||||
private suppressEcho = false
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
camera: THREE.Camera,
|
||||
domElement: HTMLElement,
|
||||
private readonly onDraggingChange: DraggingChangeListener,
|
||||
private readonly onChange: ChangeListener
|
||||
) {
|
||||
this.proxy = new THREE.Object3D()
|
||||
this.proxy.name = 'CameraInfoCameraProxy'
|
||||
|
||||
this.controls = new TransformControls(camera, domElement)
|
||||
this.controls.setMode(this.mode)
|
||||
this.controls.setSize(0.8)
|
||||
this.controls.setSpace(spaceFor(this.mode))
|
||||
this.controls.attach(this.proxy)
|
||||
this.helper = this.controls.getHelper()
|
||||
this.helper.name = 'CameraInfoCameraHandle'
|
||||
this.helper.visible = false
|
||||
this.controls.enabled = false
|
||||
|
||||
this.controls.addEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.addEventListener('objectChange', this.onObjectChange)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.proxy)
|
||||
scene.add(this.helper)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.proxy)
|
||||
this.scene.remove(this.helper)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.helper.visible = visible
|
||||
this.controls.enabled = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.helper.visible
|
||||
}
|
||||
|
||||
setMode(mode: CameraHandleMode): void {
|
||||
if (this.mode === mode) return
|
||||
this.mode = mode
|
||||
this.controls.setMode(mode)
|
||||
this.controls.setSpace(spaceFor(mode))
|
||||
}
|
||||
|
||||
getMode(): CameraHandleMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
setSubject(
|
||||
position: THREE.Vector3Like,
|
||||
quaternion: { x: number; y: number; z: number; w: number }
|
||||
): void {
|
||||
const samePosition =
|
||||
this.proxy.position.x === position.x &&
|
||||
this.proxy.position.y === position.y &&
|
||||
this.proxy.position.z === position.z
|
||||
const sameQuaternion =
|
||||
this.proxy.quaternion.x === quaternion.x &&
|
||||
this.proxy.quaternion.y === quaternion.y &&
|
||||
this.proxy.quaternion.z === quaternion.z &&
|
||||
this.proxy.quaternion.w === quaternion.w
|
||||
if (samePosition && sameQuaternion) return
|
||||
this.suppressEcho = true
|
||||
try {
|
||||
this.proxy.position.set(position.x, position.y, position.z)
|
||||
this.proxy.quaternion.set(
|
||||
quaternion.x,
|
||||
quaternion.y,
|
||||
quaternion.z,
|
||||
quaternion.w
|
||||
)
|
||||
this.proxy.updateMatrixWorld(true)
|
||||
} finally {
|
||||
this.suppressEcho = false
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.controls.removeEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.removeEventListener('objectChange', this.onObjectChange)
|
||||
this.controls.detach()
|
||||
this.detach()
|
||||
this.controls.dispose()
|
||||
}
|
||||
|
||||
private readonly onDragging = (event: { value: unknown }): void => {
|
||||
this.onDraggingChange(event.value === true)
|
||||
}
|
||||
|
||||
private readonly onObjectChange = (): void => {
|
||||
if (this.suppressEcho) return
|
||||
this.onChange(
|
||||
{
|
||||
position: {
|
||||
x: this.proxy.position.x,
|
||||
y: this.proxy.position.y,
|
||||
z: this.proxy.position.z
|
||||
},
|
||||
quaternion: {
|
||||
x: this.proxy.quaternion.x,
|
||||
y: this.proxy.quaternion.y,
|
||||
z: this.proxy.quaternion.z,
|
||||
w: this.proxy.quaternion.w
|
||||
}
|
||||
},
|
||||
this.mode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function spaceFor(mode: CameraHandleMode): 'world' | 'local' {
|
||||
return mode === 'translate' ? 'world' : 'local'
|
||||
}
|
||||
108
src/extensions/core/cameraInfo/handles/OrbitHandles.test.ts
Normal file
108
src/extensions/core/cameraInfo/handles/OrbitHandles.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
|
||||
import { OrbitHandles } from './OrbitHandles'
|
||||
|
||||
describe('OrbitHandles', () => {
|
||||
let scene: THREE.Scene
|
||||
let handles: OrbitHandles
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
handles = new OrbitHandles()
|
||||
handles.attach(scene)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handles.dispose()
|
||||
})
|
||||
|
||||
it('adds a single root group to the scene on attach', () => {
|
||||
const root = scene.children.find((c) => c.name === 'CameraInfoOrbitHandles')
|
||||
expect(root).toBeDefined()
|
||||
})
|
||||
|
||||
it('exposes three pickable handle meshes tagged with handleType', () => {
|
||||
const meshes = handles.pickableMeshes()
|
||||
expect(meshes).toHaveLength(3)
|
||||
const types = meshes.map((m) => m.userData.handleType).sort()
|
||||
expect(types).toEqual(['distance', 'pitch', 'yaw'])
|
||||
})
|
||||
|
||||
it('positions the yaw handle on the +Z side of the ring at yaw=0', () => {
|
||||
handles.update({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const yawHandle = handles
|
||||
.pickableMeshes()
|
||||
.find((m) => m.userData.handleType === 'yaw')!
|
||||
expect(yawHandle.position.x).toBeCloseTo(0)
|
||||
expect(yawHandle.position.z).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('places the distance handle at the camera (distance along yaw direction)', () => {
|
||||
handles.update({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 90, pitch: 0, distance: 7 }
|
||||
})
|
||||
|
||||
const distanceHandle = handles
|
||||
.pickableMeshes()
|
||||
.find((m) => m.userData.handleType === 'distance')!
|
||||
const world = new THREE.Vector3()
|
||||
distanceHandle.getWorldPosition(world)
|
||||
expect(world.x).toBeCloseTo(7)
|
||||
expect(world.y).toBeCloseTo(0)
|
||||
expect(world.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('hides itself in non-orbit modes', () => {
|
||||
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
|
||||
|
||||
const root = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoOrbitHandles'
|
||||
)!
|
||||
expect(root.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('setVisible forces visibility independent of mode', () => {
|
||||
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
|
||||
handles.setVisible(false)
|
||||
|
||||
const root = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoOrbitHandles'
|
||||
)!
|
||||
expect(root.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('isVisible reflects the root group, not individual handle meshes', () => {
|
||||
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
|
||||
|
||||
expect(handles.isVisible()).toBe(false)
|
||||
expect(handles.pickableMeshes()[0].visible).toBe(true)
|
||||
})
|
||||
|
||||
it('yaw drag plane is horizontal through target', () => {
|
||||
const plane = handles.dragPlaneFor('yaw', {
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
target: { x: 0, y: 3, z: 0 }
|
||||
})
|
||||
expect(plane.normal.y).toBeCloseTo(1)
|
||||
expect(plane.constant).toBeCloseTo(-3)
|
||||
})
|
||||
|
||||
it('pitch drag plane is vertical and orthogonal to yaw direction', () => {
|
||||
const plane = handles.dragPlaneFor('pitch', {
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
expect(Math.abs(plane.normal.x)).toBeCloseTo(1)
|
||||
expect(plane.normal.y).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
258
src/extensions/core/cameraInfo/handles/OrbitHandles.ts
Normal file
258
src/extensions/core/cameraInfo/handles/OrbitHandles.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { CameraInfoState } from '../types'
|
||||
|
||||
const RING_RADIUS = 1.5
|
||||
const HANDLE_RADIUS = 0.08
|
||||
const GLOW_RADIUS = 0.12
|
||||
const TUBE_RADIUS = 0.025
|
||||
const ARC_PITCH_LIMIT = 85
|
||||
const ARC_SEGMENTS = 32
|
||||
|
||||
const YAW_COLOR = 0x00d4ff
|
||||
const PITCH_COLOR = 0xff66ff
|
||||
const DISTANCE_COLOR = 0xffcc00
|
||||
|
||||
const BASE_GLOW_OPACITY = 0.25
|
||||
const HOVER_GLOW_OPACITY = 0.6
|
||||
const HOVER_SCALE = 1.35
|
||||
|
||||
export type OrbitHandleType = 'yaw' | 'pitch' | 'distance'
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
function buildArcCurve(): THREE.CatmullRomCurve3 {
|
||||
const points: THREE.Vector3[] = []
|
||||
for (let deg = -ARC_PITCH_LIMIT; deg <= ARC_PITCH_LIMIT; deg += 5) {
|
||||
const r = deg * DEG2RAD
|
||||
points.push(
|
||||
new THREE.Vector3(0, RING_RADIUS * Math.sin(r), RING_RADIUS * Math.cos(r))
|
||||
)
|
||||
}
|
||||
return new THREE.CatmullRomCurve3(points)
|
||||
}
|
||||
|
||||
function makeHandle(color: number, type: OrbitHandleType): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.3,
|
||||
metalness: 0.2
|
||||
})
|
||||
)
|
||||
mesh.userData.handleType = type
|
||||
return mesh
|
||||
}
|
||||
|
||||
function makeGlow(color: number): THREE.Mesh {
|
||||
return new THREE.Mesh(
|
||||
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: BASE_GLOW_OPACITY,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export class OrbitHandles {
|
||||
private readonly root = new THREE.Group()
|
||||
private readonly yawGroup = new THREE.Group()
|
||||
|
||||
private readonly yawRing: THREE.Mesh
|
||||
private readonly pitchArc: THREE.Mesh
|
||||
private readonly distanceLine: THREE.Line
|
||||
private readonly distanceLineGeometry: THREE.BufferGeometry
|
||||
|
||||
private readonly yawHandle: THREE.Mesh
|
||||
private readonly pitchHandle: THREE.Mesh
|
||||
private readonly distanceHandle: THREE.Mesh
|
||||
|
||||
private readonly yawHandleGlow: THREE.Mesh
|
||||
private readonly pitchHandleGlow: THREE.Mesh
|
||||
private readonly distanceHandleGlow: THREE.Mesh
|
||||
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor() {
|
||||
this.root.name = 'CameraInfoOrbitHandles'
|
||||
this.root.add(this.yawGroup)
|
||||
|
||||
this.yawRing = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 96),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: YAW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.yawRing.rotation.x = Math.PI / 2
|
||||
this.root.add(this.yawRing)
|
||||
|
||||
this.pitchArc = new THREE.Mesh(
|
||||
new THREE.TubeGeometry(
|
||||
buildArcCurve(),
|
||||
ARC_SEGMENTS,
|
||||
TUBE_RADIUS,
|
||||
8,
|
||||
false
|
||||
),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: PITCH_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.yawGroup.add(this.pitchArc)
|
||||
|
||||
this.distanceLineGeometry = new THREE.BufferGeometry()
|
||||
this.distanceLineGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, 1], 3)
|
||||
)
|
||||
this.distanceLine = new THREE.Line(
|
||||
this.distanceLineGeometry,
|
||||
new THREE.LineBasicMaterial({
|
||||
color: DISTANCE_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.yawGroup.add(this.distanceLine)
|
||||
|
||||
this.yawHandle = makeHandle(YAW_COLOR, 'yaw')
|
||||
this.yawHandleGlow = makeGlow(YAW_COLOR)
|
||||
this.yawHandle.add(this.yawHandleGlow)
|
||||
this.root.add(this.yawHandle)
|
||||
|
||||
this.pitchHandle = makeHandle(PITCH_COLOR, 'pitch')
|
||||
this.pitchHandleGlow = makeGlow(PITCH_COLOR)
|
||||
this.pitchHandle.add(this.pitchHandleGlow)
|
||||
this.yawGroup.add(this.pitchHandle)
|
||||
|
||||
this.distanceHandle = makeHandle(DISTANCE_COLOR, 'distance')
|
||||
this.distanceHandleGlow = makeGlow(DISTANCE_COLOR)
|
||||
this.distanceHandle.add(this.distanceHandleGlow)
|
||||
this.yawGroup.add(this.distanceHandle)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.root)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.root)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.detach()
|
||||
const disposables: { dispose: () => void }[] = [
|
||||
this.yawRing.geometry,
|
||||
this.yawRing.material as THREE.Material,
|
||||
this.pitchArc.geometry,
|
||||
this.pitchArc.material as THREE.Material,
|
||||
this.distanceLineGeometry,
|
||||
this.distanceLine.material as THREE.Material,
|
||||
this.yawHandle.geometry,
|
||||
this.yawHandle.material as THREE.Material,
|
||||
this.pitchHandle.geometry,
|
||||
this.pitchHandle.material as THREE.Material,
|
||||
this.distanceHandle.geometry,
|
||||
this.distanceHandle.material as THREE.Material,
|
||||
this.yawHandleGlow.geometry,
|
||||
this.yawHandleGlow.material as THREE.Material,
|
||||
this.pitchHandleGlow.geometry,
|
||||
this.pitchHandleGlow.material as THREE.Material,
|
||||
this.distanceHandleGlow.geometry,
|
||||
this.distanceHandleGlow.material as THREE.Material
|
||||
]
|
||||
for (const d of disposables) d.dispose()
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.root.visible = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.root.visible
|
||||
}
|
||||
|
||||
update(state: CameraInfoState): void {
|
||||
const visible = state.mode === 'orbit'
|
||||
this.root.visible = visible
|
||||
if (!visible) return
|
||||
|
||||
this.root.position.set(state.target.x, state.target.y, state.target.z)
|
||||
|
||||
const yawRad = state.orbit.yaw * DEG2RAD
|
||||
const pitchRad = state.orbit.pitch * DEG2RAD
|
||||
const cosP = Math.cos(pitchRad)
|
||||
const sinP = Math.sin(pitchRad)
|
||||
|
||||
this.yawGroup.rotation.y = yawRad
|
||||
|
||||
this.yawHandle.position.set(
|
||||
RING_RADIUS * Math.sin(yawRad),
|
||||
0,
|
||||
RING_RADIUS * Math.cos(yawRad)
|
||||
)
|
||||
|
||||
this.pitchHandle.position.set(0, RING_RADIUS * sinP, RING_RADIUS * cosP)
|
||||
|
||||
const endY = state.orbit.distance * sinP
|
||||
const endZ = state.orbit.distance * cosP
|
||||
this.distanceHandle.position.set(0, endY, endZ)
|
||||
|
||||
const positions = this.distanceLineGeometry.attributes
|
||||
.position as THREE.BufferAttribute
|
||||
positions.setXYZ(1, 0, endY, endZ)
|
||||
positions.needsUpdate = true
|
||||
}
|
||||
|
||||
pickableMeshes(): THREE.Object3D[] {
|
||||
return [this.yawHandle, this.pitchHandle, this.distanceHandle]
|
||||
}
|
||||
|
||||
setHovered(type: OrbitHandleType | null): void {
|
||||
const entries: [THREE.Mesh, THREE.Mesh, OrbitHandleType][] = [
|
||||
[this.yawHandle, this.yawHandleGlow, 'yaw'],
|
||||
[this.pitchHandle, this.pitchHandleGlow, 'pitch'],
|
||||
[this.distanceHandle, this.distanceHandleGlow, 'distance']
|
||||
]
|
||||
for (const [handle, glow, handleType] of entries) {
|
||||
const active = handleType === type
|
||||
handle.scale.setScalar(active ? HOVER_SCALE : 1)
|
||||
;(glow.material as THREE.MeshBasicMaterial).opacity = active
|
||||
? HOVER_GLOW_OPACITY
|
||||
: BASE_GLOW_OPACITY
|
||||
}
|
||||
}
|
||||
|
||||
dragPlaneFor(type: OrbitHandleType, state: CameraInfoState): THREE.Plane {
|
||||
if (type === 'yaw') {
|
||||
return new THREE.Plane(new THREE.Vector3(0, 1, 0), -state.target.y)
|
||||
}
|
||||
const yawRad = state.orbit.yaw * DEG2RAD
|
||||
const normal = new THREE.Vector3(
|
||||
-Math.cos(yawRad),
|
||||
0,
|
||||
Math.sin(yawRad)
|
||||
).normalize()
|
||||
const targetVec = vec(state.target)
|
||||
return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, targetVec)
|
||||
}
|
||||
}
|
||||
|
||||
const vec = (v: THREE.Vector3Like): THREE.Vector3 =>
|
||||
new THREE.Vector3(v.x, v.y, v.z)
|
||||
78
src/extensions/core/cameraInfo/handles/RollHandle.test.ts
Normal file
78
src/extensions/core/cameraInfo/handles/RollHandle.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
|
||||
import { RollHandle } from './RollHandle'
|
||||
|
||||
describe('RollHandle', () => {
|
||||
let scene: THREE.Scene
|
||||
let handle: RollHandle
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
handle = new RollHandle()
|
||||
handle.attach(scene)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('attaches a single root group to the scene', () => {
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoRollHandle')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('exposes one pickable mesh tagged handleType="roll"', () => {
|
||||
const meshes = handle.pickableMeshes()
|
||||
expect(meshes).toHaveLength(1)
|
||||
expect(meshes[0].userData.handleType).toBe('roll')
|
||||
})
|
||||
|
||||
it('hides itself in quaternion mode (rotate gizmo owns roll there)', () => {
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
|
||||
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('is visible in orbit and look_at modes', () => {
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('setVisible forces visibility', () => {
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
|
||||
handle.setVisible(false)
|
||||
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('positions itself at the target', () => {
|
||||
handle.update({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
target: { x: 1, y: 2, z: 3 }
|
||||
})
|
||||
|
||||
const root = scene.children.find((c) => c.name === 'CameraInfoRollHandle')!
|
||||
expect(root.position.x).toBeCloseTo(1)
|
||||
expect(root.position.y).toBeCloseTo(2)
|
||||
expect(root.position.z).toBeCloseTo(3)
|
||||
})
|
||||
|
||||
it('drag plane normal points along the camera→target backward direction', () => {
|
||||
const plane = handle.dragPlane({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'look_at',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } }
|
||||
})
|
||||
expect(plane.normal.x).toBeCloseTo(0)
|
||||
expect(plane.normal.y).toBeCloseTo(0)
|
||||
expect(Math.abs(plane.normal.z)).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
160
src/extensions/core/cameraInfo/handles/RollHandle.ts
Normal file
160
src/extensions/core/cameraInfo/handles/RollHandle.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { computeSubjectTransform } from '../cameraTransform'
|
||||
import type { CameraInfoState } from '../types'
|
||||
import { rollBasis } from './rollDragMath'
|
||||
|
||||
const RING_RADIUS = 0.9
|
||||
const HANDLE_RADIUS = 0.08
|
||||
const GLOW_RADIUS = 0.12
|
||||
const TUBE_RADIUS = 0.025
|
||||
|
||||
const ROLL_COLOR = 0xff8800
|
||||
const BASE_GLOW_OPACITY = 0.25
|
||||
const HOVER_GLOW_OPACITY = 0.6
|
||||
const HOVER_SCALE = 1.35
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
function makeHandle(): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: ROLL_COLOR,
|
||||
emissive: ROLL_COLOR,
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.3,
|
||||
metalness: 0.2
|
||||
})
|
||||
)
|
||||
mesh.userData.handleType = 'roll'
|
||||
return mesh
|
||||
}
|
||||
|
||||
export class RollHandle {
|
||||
private readonly root = new THREE.Group()
|
||||
private readonly ring: THREE.Mesh
|
||||
private readonly handle: THREE.Mesh
|
||||
private readonly handleGlow: THREE.Mesh
|
||||
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor() {
|
||||
this.root.name = 'CameraInfoRollHandle'
|
||||
|
||||
this.ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 80),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: ROLL_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.root.add(this.ring)
|
||||
|
||||
this.handle = makeHandle()
|
||||
this.handleGlow = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: ROLL_COLOR,
|
||||
transparent: true,
|
||||
opacity: BASE_GLOW_OPACITY,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
)
|
||||
this.handle.add(this.handleGlow)
|
||||
this.root.add(this.handle)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.root)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.root)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.detach()
|
||||
const disposables: { dispose: () => void }[] = [
|
||||
this.ring.geometry,
|
||||
this.ring.material as THREE.Material,
|
||||
this.handle.geometry,
|
||||
this.handle.material as THREE.Material,
|
||||
this.handleGlow.geometry,
|
||||
this.handleGlow.material as THREE.Material
|
||||
]
|
||||
for (const d of disposables) d.dispose()
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.root.visible = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.root.visible
|
||||
}
|
||||
|
||||
update(state: CameraInfoState): void {
|
||||
if (state.mode === 'quaternion') {
|
||||
this.root.visible = false
|
||||
return
|
||||
}
|
||||
this.root.visible = true
|
||||
|
||||
const { position: cameraPos } = computeSubjectTransform(state)
|
||||
const cameraVec: THREE.Vector3Like = {
|
||||
x: cameraPos.x,
|
||||
y: cameraPos.y,
|
||||
z: cameraPos.z
|
||||
}
|
||||
const { up, right, backward } = rollBasis(state.target, cameraVec)
|
||||
|
||||
this.root.position.set(state.target.x, state.target.y, state.target.z)
|
||||
const orient = new THREE.Quaternion().setFromRotationMatrix(
|
||||
new THREE.Matrix4().makeBasis(right, up, backward)
|
||||
)
|
||||
this.root.quaternion.copy(orient)
|
||||
|
||||
const theta = state.roll * DEG2RAD
|
||||
this.handle.position.set(
|
||||
RING_RADIUS * Math.sin(theta),
|
||||
RING_RADIUS * Math.cos(theta),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
pickableMeshes(): THREE.Object3D[] {
|
||||
return [this.handle]
|
||||
}
|
||||
|
||||
setHovered(hovered: boolean): void {
|
||||
this.handle.scale.setScalar(hovered ? HOVER_SCALE : 1)
|
||||
;(this.handleGlow.material as THREE.MeshBasicMaterial).opacity = hovered
|
||||
? HOVER_GLOW_OPACITY
|
||||
: BASE_GLOW_OPACITY
|
||||
}
|
||||
|
||||
dragPlane(state: CameraInfoState): THREE.Plane {
|
||||
const { position: cameraPos } = computeSubjectTransform(state)
|
||||
const cameraVec: THREE.Vector3Like = {
|
||||
x: cameraPos.x,
|
||||
y: cameraPos.y,
|
||||
z: cameraPos.z
|
||||
}
|
||||
const { backward } = rollBasis(state.target, cameraVec)
|
||||
const tgt = new THREE.Vector3(
|
||||
state.target.x,
|
||||
state.target.y,
|
||||
state.target.z
|
||||
)
|
||||
return new THREE.Plane().setFromNormalAndCoplanarPoint(backward, tgt)
|
||||
}
|
||||
}
|
||||
84
src/extensions/core/cameraInfo/handles/TargetHandle.test.ts
Normal file
84
src/extensions/core/cameraInfo/handles/TargetHandle.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as THREE from 'three'
|
||||
import type { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TargetHandle } from './TargetHandle'
|
||||
|
||||
describe('TargetHandle', () => {
|
||||
let scene: THREE.Scene
|
||||
let onDragging: ReturnType<typeof vi.fn>
|
||||
let onChange: ReturnType<typeof vi.fn>
|
||||
let handle: TargetHandle
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
onDragging = vi.fn()
|
||||
onChange = vi.fn()
|
||||
handle = new TargetHandle(
|
||||
new THREE.PerspectiveCamera(),
|
||||
document.createElement('div'),
|
||||
onDragging as unknown as (dragging: boolean) => void,
|
||||
onChange as unknown as (target: THREE.Vector3Like) => void
|
||||
)
|
||||
handle.attach(scene)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
function proxy(): THREE.Object3D {
|
||||
return scene.children.find((c) => c.name === 'CameraInfoTargetProxy')!
|
||||
}
|
||||
|
||||
function controls(): TransformControls {
|
||||
return (handle as unknown as { controls: TransformControls }).controls
|
||||
}
|
||||
|
||||
it('adds proxy + helper to the scene on attach', () => {
|
||||
expect(proxy()).toBeDefined()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoTargetHandle')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts hidden and toggles visibility', () => {
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
handle.setVisible(true)
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('setTarget moves the proxy without echoing onChange', () => {
|
||||
handle.setTarget({ x: 1, y: 2, z: 3 })
|
||||
|
||||
expect(proxy().position.toArray()).toEqual([1, 2, 3])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setTarget is a no-op when the position already matches', () => {
|
||||
handle.setTarget({ x: 0, y: 0, z: 0 })
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits onChange with the proxy position on gizmo objectChange', () => {
|
||||
proxy().position.set(4, 5, 6)
|
||||
controls().dispatchEvent({ type: 'objectChange' })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ x: 4, y: 5, z: 6 })
|
||||
})
|
||||
|
||||
it('forwards dragging-changed to onDraggingChange', () => {
|
||||
controls().dispatchEvent({ type: 'dragging-changed', value: true })
|
||||
|
||||
expect(onDragging).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('dispose removes proxy and helper', () => {
|
||||
handle.dispose()
|
||||
|
||||
expect(proxy()).toBeUndefined()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoTargetHandle')
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
99
src/extensions/core/cameraInfo/handles/TargetHandle.ts
Normal file
99
src/extensions/core/cameraInfo/handles/TargetHandle.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as THREE from 'three'
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
|
||||
import type { DraggingChangeListener } from './types'
|
||||
|
||||
type ChangeListener = (target: THREE.Vector3Like) => void
|
||||
|
||||
export class TargetHandle {
|
||||
private readonly proxy: THREE.Object3D
|
||||
private readonly controls: TransformControls
|
||||
private readonly helper: THREE.Object3D
|
||||
private suppressEcho = false
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
camera: THREE.Camera,
|
||||
domElement: HTMLElement,
|
||||
private readonly onDraggingChange: DraggingChangeListener,
|
||||
private readonly onChange: ChangeListener
|
||||
) {
|
||||
this.proxy = new THREE.Object3D()
|
||||
this.proxy.name = 'CameraInfoTargetProxy'
|
||||
|
||||
this.controls = new TransformControls(camera, domElement)
|
||||
this.controls.setMode('translate')
|
||||
this.controls.setSize(0.8)
|
||||
this.controls.attach(this.proxy)
|
||||
this.helper = this.controls.getHelper()
|
||||
this.helper.name = 'CameraInfoTargetHandle'
|
||||
this.helper.visible = false
|
||||
this.controls.enabled = false
|
||||
|
||||
this.controls.addEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.addEventListener('objectChange', this.onObjectChange)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.proxy)
|
||||
scene.add(this.helper)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.proxy)
|
||||
this.scene.remove(this.helper)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.helper.visible = visible
|
||||
this.controls.enabled = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.helper.visible
|
||||
}
|
||||
|
||||
setTarget(target: THREE.Vector3Like): void {
|
||||
if (
|
||||
this.proxy.position.x === target.x &&
|
||||
this.proxy.position.y === target.y &&
|
||||
this.proxy.position.z === target.z
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.suppressEcho = true
|
||||
try {
|
||||
this.proxy.position.set(target.x, target.y, target.z)
|
||||
this.proxy.updateMatrixWorld(true)
|
||||
} finally {
|
||||
this.suppressEcho = false
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.controls.removeEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.removeEventListener('objectChange', this.onObjectChange)
|
||||
this.controls.detach()
|
||||
this.detach()
|
||||
this.controls.dispose()
|
||||
}
|
||||
|
||||
private readonly onDragging = (event: { value: unknown }): void => {
|
||||
this.onDraggingChange(event.value === true)
|
||||
}
|
||||
|
||||
private readonly onObjectChange = (): void => {
|
||||
if (this.suppressEcho) return
|
||||
this.onChange({
|
||||
x: this.proxy.position.x,
|
||||
y: this.proxy.position.y,
|
||||
z: this.proxy.position.z
|
||||
})
|
||||
}
|
||||
}
|
||||
104
src/extensions/core/cameraInfo/handles/handlePicking.test.ts
Normal file
104
src/extensions/core/cameraInfo/handles/handlePicking.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { pickHandleAtPointer } from './handlePicking'
|
||||
|
||||
const CANVAS = { clientWidth: 400, clientHeight: 400 }
|
||||
|
||||
function makeHandle(handleType: string, position: THREE.Vector3): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8))
|
||||
mesh.userData.handleType = handleType
|
||||
mesh.position.copy(position)
|
||||
mesh.updateMatrixWorld(true)
|
||||
return mesh
|
||||
}
|
||||
|
||||
describe('pickHandleAtPointer', () => {
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let raycaster: THREE.Raycaster
|
||||
|
||||
beforeEach(() => {
|
||||
camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100)
|
||||
camera.position.set(0, 0, 10)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateMatrixWorld(true)
|
||||
camera.updateProjectionMatrix()
|
||||
raycaster = new THREE.Raycaster()
|
||||
})
|
||||
|
||||
it('returns the handle type on a direct raycast hit', () => {
|
||||
const handle = makeHandle('yaw', new THREE.Vector3(0, 0, 0))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0, 0),
|
||||
camera,
|
||||
[handle],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBe('yaw')
|
||||
})
|
||||
|
||||
it('picks a handle within the screen-space tolerance on a near miss', () => {
|
||||
const handle = makeHandle('pitch', new THREE.Vector3(0, 0, 0))
|
||||
// ~10px off centre: outside the 0.08-radius sphere but inside tolerance.
|
||||
const nearMissNdc = new THREE.Vector2(10 / (CANVAS.clientWidth / 2), 0)
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
nearMissNdc,
|
||||
camera,
|
||||
[handle],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBe('pitch')
|
||||
})
|
||||
|
||||
it('returns null when the pointer is far from every handle', () => {
|
||||
const handle = makeHandle('distance', new THREE.Vector3(0, 0, 0))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0.5, 0.5),
|
||||
camera,
|
||||
[handle],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the nearest handle when several are within tolerance', () => {
|
||||
// The pointer misses both spheres (no direct raycast hit) but sits inside
|
||||
// the screen-space tolerance of each centre; the nearer centre wins.
|
||||
const near = makeHandle('yaw', new THREE.Vector3(0.05, 0, 5))
|
||||
const far = makeHandle('pitch', new THREE.Vector3(-0.05, 0, 5))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0.01, -0.04),
|
||||
camera,
|
||||
[far, near],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBe('yaw')
|
||||
})
|
||||
|
||||
it('ignores handles behind the camera', () => {
|
||||
const behind = makeHandle('yaw', new THREE.Vector3(0, 0, 20))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0, 0),
|
||||
camera,
|
||||
[behind],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null with no targets', () => {
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0, 0),
|
||||
camera,
|
||||
[],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBeNull()
|
||||
})
|
||||
})
|
||||
51
src/extensions/core/cameraInfo/handles/handlePicking.ts
Normal file
51
src/extensions/core/cameraInfo/handles/handlePicking.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
const PICK_PIXEL_RADIUS = 14
|
||||
|
||||
interface CanvasSize {
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
function toScreenPx(
|
||||
ndcX: number,
|
||||
ndcY: number,
|
||||
canvas: CanvasSize
|
||||
): THREE.Vector2 {
|
||||
return new THREE.Vector2(
|
||||
(ndcX + 1) * 0.5 * canvas.clientWidth,
|
||||
(1 - (ndcY + 1) * 0.5) * canvas.clientHeight
|
||||
)
|
||||
}
|
||||
|
||||
export function pickHandleAtPointer<T extends string>(
|
||||
raycaster: THREE.Raycaster,
|
||||
pointerNdc: THREE.Vector2,
|
||||
camera: THREE.Camera,
|
||||
targets: THREE.Object3D[],
|
||||
canvas: CanvasSize
|
||||
): T | null {
|
||||
if (targets.length === 0) return null
|
||||
|
||||
raycaster.setFromCamera(pointerNdc, camera)
|
||||
const hits = raycaster.intersectObjects(targets, false)
|
||||
if (hits.length > 0) return hits[0].object.userData.handleType as T
|
||||
|
||||
const pointerPx = toScreenPx(pointerNdc.x, pointerNdc.y, canvas)
|
||||
const world = new THREE.Vector3()
|
||||
let best: T | null = null
|
||||
let bestDistance = PICK_PIXEL_RADIUS
|
||||
for (const target of targets) {
|
||||
target.getWorldPosition(world)
|
||||
const view = world.clone().applyMatrix4(camera.matrixWorldInverse)
|
||||
if (view.z >= 0) continue // behind the camera
|
||||
const ndc = world.project(camera)
|
||||
const px = toScreenPx(ndc.x, ndc.y, canvas)
|
||||
const distance = px.distanceTo(pointerPx)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
best = target.userData.handleType as T
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
107
src/extensions/core/cameraInfo/handles/orbitDragMath.test.ts
Normal file
107
src/extensions/core/cameraInfo/handles/orbitDragMath.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAX_DISTANCE,
|
||||
MAX_PITCH,
|
||||
MIN_DISTANCE,
|
||||
MIN_PITCH,
|
||||
pointToDistance,
|
||||
pointToPitchAngle,
|
||||
pointToYawAngle
|
||||
} from './orbitDragMath'
|
||||
|
||||
const TARGET = { x: 0, y: 0, z: 0 }
|
||||
const SHIFTED_TARGET = { x: 2, y: 1, z: -3 }
|
||||
|
||||
describe('pointToYawAngle', () => {
|
||||
it('returns 0 when the point sits on +Z relative to the target', () => {
|
||||
expect(pointToYawAngle({ x: 0, y: 0, z: 5 }, TARGET)).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns 90 when the point sits on +X (rotation around Y)', () => {
|
||||
expect(pointToYawAngle({ x: 5, y: 0, z: 0 }, TARGET)).toBeCloseTo(90)
|
||||
})
|
||||
|
||||
it('returns -90 when the point sits on -X', () => {
|
||||
expect(pointToYawAngle({ x: -5, y: 0, z: 0 }, TARGET)).toBeCloseTo(-90)
|
||||
})
|
||||
|
||||
it('returns 180 (or -180) when the point sits on -Z', () => {
|
||||
expect(
|
||||
Math.abs(pointToYawAngle({ x: 0, y: 0, z: -5 }, TARGET))
|
||||
).toBeCloseTo(180)
|
||||
})
|
||||
|
||||
it('is invariant to the y component of the point and target', () => {
|
||||
expect(
|
||||
pointToYawAngle({ x: 3, y: 99, z: 4 }, { x: 0, y: -7, z: 0 })
|
||||
).toBeCloseTo(pointToYawAngle({ x: 3, y: 0, z: 4 }, TARGET))
|
||||
})
|
||||
|
||||
it('works against a non-origin target', () => {
|
||||
expect(
|
||||
pointToYawAngle(
|
||||
{ x: SHIFTED_TARGET.x, y: 0, z: SHIFTED_TARGET.z + 5 },
|
||||
SHIFTED_TARGET
|
||||
)
|
||||
).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointToPitchAngle', () => {
|
||||
it('returns 0 when the point lies in the horizontal plane', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: 0, z: 5 }, TARGET, 0)).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns +45 for a point lifted to (0, h, h) at yaw=0', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: 5, z: 5 }, TARGET, 0)).toBeCloseTo(45)
|
||||
})
|
||||
|
||||
it('returns -30 for a downward-pointing intersection at yaw=0', () => {
|
||||
const h = 5
|
||||
const v = h * Math.tan((30 * Math.PI) / 180)
|
||||
expect(pointToPitchAngle({ x: 0, y: -v, z: h }, TARGET, 0)).toBeCloseTo(-30)
|
||||
})
|
||||
|
||||
it('respects the current yaw direction when projecting horizontally', () => {
|
||||
expect(pointToPitchAngle({ x: 5, y: 5, z: 0 }, TARGET, 90)).toBeCloseTo(45)
|
||||
})
|
||||
|
||||
it('clamps to MAX_PITCH past the upper pole', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: 100, z: 0.001 }, TARGET, 0)).toBe(
|
||||
MAX_PITCH
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps to MIN_PITCH past the lower pole', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: -100, z: 0.001 }, TARGET, 0)).toBe(
|
||||
MIN_PITCH
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointToDistance', () => {
|
||||
it('returns the radial projection at yaw=0, pitch=0', () => {
|
||||
expect(pointToDistance({ x: 0, y: 0, z: 7 }, TARGET, 0, 0)).toBeCloseTo(7)
|
||||
})
|
||||
|
||||
it('returns the radial projection along the current yaw direction', () => {
|
||||
expect(pointToDistance({ x: 4, y: 0, z: 0 }, TARGET, 90, 0)).toBeCloseTo(4)
|
||||
})
|
||||
|
||||
it('ignores off-axis components of the point', () => {
|
||||
expect(pointToDistance({ x: 99, y: 0, z: 5 }, TARGET, 0, 0)).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('clamps to MIN_DISTANCE when the projection goes behind the target', () => {
|
||||
expect(pointToDistance({ x: 0, y: 0, z: -10 }, TARGET, 0, 0)).toBe(
|
||||
MIN_DISTANCE
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps to MAX_DISTANCE when the projection overshoots', () => {
|
||||
expect(pointToDistance({ x: 0, y: 0, z: 99999 }, TARGET, 0, 0)).toBe(
|
||||
MAX_DISTANCE
|
||||
)
|
||||
})
|
||||
})
|
||||
50
src/extensions/core/cameraInfo/handles/orbitDragMath.ts
Normal file
50
src/extensions/core/cameraInfo/handles/orbitDragMath.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import type { Vector3Like } from 'three'
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
export const MIN_DISTANCE = 0.5
|
||||
export const MAX_DISTANCE = 100
|
||||
export const MIN_PITCH = -89
|
||||
export const MAX_PITCH = 89
|
||||
|
||||
export function pointToYawAngle(
|
||||
point: Vector3Like,
|
||||
target: Vector3Like
|
||||
): number {
|
||||
return Math.atan2(point.x - target.x, point.z - target.z) * RAD2DEG
|
||||
}
|
||||
|
||||
export function pointToPitchAngle(
|
||||
point: Vector3Like,
|
||||
target: Vector3Like,
|
||||
yawDeg: number
|
||||
): number {
|
||||
const y = yawDeg * DEG2RAD
|
||||
const dx = point.x - target.x
|
||||
const dy = point.y - target.y
|
||||
const dz = point.z - target.z
|
||||
const horizontal = dx * Math.sin(y) + dz * Math.cos(y)
|
||||
const raw = Math.atan2(dy, horizontal) * RAD2DEG
|
||||
return clamp(raw, MIN_PITCH, MAX_PITCH)
|
||||
}
|
||||
|
||||
export function pointToDistance(
|
||||
point: Vector3Like,
|
||||
target: Vector3Like,
|
||||
yawDeg: number,
|
||||
pitchDeg: number
|
||||
): number {
|
||||
const y = yawDeg * DEG2RAD
|
||||
const p = pitchDeg * DEG2RAD
|
||||
const dirX = Math.cos(p) * Math.sin(y)
|
||||
const dirY = Math.sin(p)
|
||||
const dirZ = Math.cos(p) * Math.cos(y)
|
||||
const dx = point.x - target.x
|
||||
const dy = point.y - target.y
|
||||
const dz = point.z - target.z
|
||||
const projection = dx * dirX + dy * dirY + dz * dirZ
|
||||
return clamp(projection, MIN_DISTANCE, MAX_DISTANCE)
|
||||
}
|
||||
75
src/extensions/core/cameraInfo/handles/rollDragMath.test.ts
Normal file
75
src/extensions/core/cameraInfo/handles/rollDragMath.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { pointToRollAngle, rollBasis } from './rollDragMath'
|
||||
|
||||
const TARGET = { x: 0, y: 0, z: 0 }
|
||||
|
||||
describe('rollBasis', () => {
|
||||
it('returns world (+X right, +Y up) when the camera looks along -Z', () => {
|
||||
const basis = rollBasis(TARGET, { x: 0, y: 0, z: 5 })
|
||||
expect(basis.up.x).toBeCloseTo(0)
|
||||
expect(basis.up.y).toBeCloseTo(1)
|
||||
expect(basis.up.z).toBeCloseTo(0)
|
||||
expect(basis.right.x).toBeCloseTo(1)
|
||||
expect(basis.right.y).toBeCloseTo(0)
|
||||
expect(basis.right.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('falls back to a +X right when the camera looks straight down', () => {
|
||||
const basis = rollBasis(TARGET, { x: 0, y: 5, z: 0 })
|
||||
expect(basis.right.x).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('orients (up, right) tangent to the camera ray for an oblique view', () => {
|
||||
const basis = rollBasis(TARGET, { x: 3, y: 4, z: 0 })
|
||||
|
||||
expect(basis.right.z).toBeCloseTo(-1)
|
||||
expect(basis.up.dot(basis.right)).toBeCloseTo(0)
|
||||
expect(basis.up.dot(basis.backward)).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns a valid orthonormal basis when the camera sits on the target', () => {
|
||||
const basis = rollBasis(TARGET, { x: 0, y: 0, z: 0 })
|
||||
|
||||
expect(basis.backward.length()).toBeCloseTo(1)
|
||||
expect(basis.right.length()).toBeCloseTo(1)
|
||||
expect(basis.up.length()).toBeCloseTo(1)
|
||||
expect(basis.up.dot(basis.right)).toBeCloseTo(0)
|
||||
expect(basis.up.dot(basis.backward)).toBeCloseTo(0)
|
||||
expect(basis.right.dot(basis.backward)).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointToRollAngle', () => {
|
||||
const CAMERA = { x: 0, y: 0, z: 5 }
|
||||
|
||||
it('returns 0 when the point sits in the +up direction', () => {
|
||||
expect(pointToRollAngle({ x: 0, y: 1, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('returns +90 when the point sits in the +right direction', () => {
|
||||
expect(pointToRollAngle({ x: 1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
|
||||
90
|
||||
)
|
||||
})
|
||||
|
||||
it('returns -90 when the point sits in the -right direction', () => {
|
||||
expect(pointToRollAngle({ x: -1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
|
||||
-90
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 180 (or -180) when the point sits in the -up direction', () => {
|
||||
expect(
|
||||
Math.abs(pointToRollAngle({ x: 0, y: -1, z: 0 }, TARGET, CAMERA))
|
||||
).toBeCloseTo(180)
|
||||
})
|
||||
|
||||
it('is invariant to the off-plane component of the point', () => {
|
||||
const base = pointToRollAngle({ x: 1, y: 1, z: 0 }, TARGET, CAMERA)
|
||||
const offPlane = pointToRollAngle({ x: 1, y: 1, z: 7 }, TARGET, CAMERA)
|
||||
expect(offPlane).toBeCloseTo(base)
|
||||
})
|
||||
})
|
||||
42
src/extensions/core/cameraInfo/handles/rollDragMath.ts
Normal file
42
src/extensions/core/cameraInfo/handles/rollDragMath.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
|
||||
export interface RollBasis {
|
||||
up: THREE.Vector3
|
||||
right: THREE.Vector3
|
||||
backward: THREE.Vector3
|
||||
}
|
||||
|
||||
export function rollBasis(
|
||||
target: THREE.Vector3Like,
|
||||
cameraPos: THREE.Vector3Like
|
||||
): RollBasis {
|
||||
const backward = new THREE.Vector3(
|
||||
cameraPos.x - target.x,
|
||||
cameraPos.y - target.y,
|
||||
cameraPos.z - target.z
|
||||
)
|
||||
if (backward.lengthSq() < 1e-8) backward.set(0, 0, 1)
|
||||
else backward.normalize()
|
||||
const worldUp = new THREE.Vector3(0, 1, 0)
|
||||
const right = new THREE.Vector3().crossVectors(worldUp, backward)
|
||||
if (right.lengthSq() < 1e-8) right.set(1, 0, 0)
|
||||
else right.normalize()
|
||||
const up = new THREE.Vector3().crossVectors(backward, right).normalize()
|
||||
return { up, right, backward }
|
||||
}
|
||||
|
||||
export function pointToRollAngle(
|
||||
point: THREE.Vector3Like,
|
||||
target: THREE.Vector3Like,
|
||||
cameraPos: THREE.Vector3Like
|
||||
): number {
|
||||
const { up, right } = rollBasis(target, cameraPos)
|
||||
const rel = new THREE.Vector3(
|
||||
point.x - target.x,
|
||||
point.y - target.y,
|
||||
point.z - target.z
|
||||
)
|
||||
return Math.atan2(rel.dot(right), rel.dot(up)) * RAD2DEG
|
||||
}
|
||||
1
src/extensions/core/cameraInfo/handles/types.ts
Normal file
1
src/extensions/core/cameraInfo/handles/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type DraggingChangeListener = (dragging: boolean) => void
|
||||
214
src/extensions/core/cameraInfo/lookThroughDragMath.test.ts
Normal file
214
src/extensions/core/cameraInfo/lookThroughDragMath.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import type { CameraInfoState } from './types'
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
|
||||
function stateWith(overrides: Partial<CameraInfoState>): CameraInfoState {
|
||||
return { ...structuredClone(DEFAULT_CAMERA_INFO_STATE), ...overrides }
|
||||
}
|
||||
|
||||
describe('rotateSubjectByDrag - orbit', () => {
|
||||
it('adds the drag deltas to yaw and pitch in degrees', () => {
|
||||
const state = stateWith({ mode: 'orbit' })
|
||||
const result = rotateSubjectByDrag(state, 0.1, 0.05)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nextState.orbit.yaw).toBeCloseTo(
|
||||
state.orbit.yaw + 0.1 * RAD2DEG
|
||||
)
|
||||
expect(result!.nextState.orbit.pitch).toBeCloseTo(
|
||||
state.orbit.pitch + 0.05 * RAD2DEG
|
||||
)
|
||||
expect(result!.updates.map((u) => u.fieldName)).toEqual([
|
||||
'mode.yaw',
|
||||
'mode.pitch'
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps pitch to the pole limit', () => {
|
||||
const state = stateWith({
|
||||
mode: 'orbit',
|
||||
orbit: { yaw: 0, pitch: 80, distance: 4 }
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0, 1)
|
||||
|
||||
expect(result!.nextState.orbit.pitch).toBe(89)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateSubjectByDrag - look_at', () => {
|
||||
it('rotates the target around a fixed camera position, preserving distance', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0.2, 0)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const pos = new THREE.Vector3(0, 0, 5)
|
||||
const before = new THREE.Vector3(0, 0, 0).distanceTo(pos)
|
||||
const nextTarget = result!.nextState.target
|
||||
const after = new THREE.Vector3(
|
||||
nextTarget.x,
|
||||
nextTarget.y,
|
||||
nextTarget.z
|
||||
).distanceTo(pos)
|
||||
|
||||
expect(after).toBeCloseTo(before)
|
||||
expect(nextTarget.x).not.toBeCloseTo(0)
|
||||
expect(result!.updates.map((u) => u.fieldName)).toEqual([
|
||||
'target_x',
|
||||
'target_y',
|
||||
'target_z'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when the camera sits on the target', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 1, y: 1, z: 1 } },
|
||||
target: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
|
||||
expect(rotateSubjectByDrag(state, 0.1, 0.1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateSubjectByDrag - quaternion', () => {
|
||||
it('rotates the orientation and keeps a unit quaternion', () => {
|
||||
const state = stateWith({
|
||||
mode: 'quaternion',
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 5 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0.2, 0.1)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const q = result!.nextState.quaternion.quat
|
||||
const length = Math.hypot(q.x, q.y, q.z, q.w)
|
||||
expect(length).toBeCloseTo(1)
|
||||
expect(q).not.toEqual({ x: 0, y: 0, z: 0, w: 1 })
|
||||
expect(result!.nextState.quaternion.position).toEqual({ x: 0, y: 0, z: 5 })
|
||||
expect(result!.updates.map((u) => u.fieldName)).toEqual([
|
||||
'mode.quat_x',
|
||||
'mode.quat_y',
|
||||
'mode.quat_z',
|
||||
'mode.quat_w'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a zero-length quaternion as identity', () => {
|
||||
const state = stateWith({
|
||||
mode: 'quaternion',
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 0 }
|
||||
}
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0.1, 0)
|
||||
const q = result!.nextState.quaternion.quat
|
||||
|
||||
expect(Math.hypot(q.x, q.y, q.z, q.w)).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dollySubjectByWheel - perspective', () => {
|
||||
it('orbit: scrolling up shortens distance, scrolling down lengthens it', () => {
|
||||
const state = stateWith({ mode: 'orbit', cameraType: 'perspective' })
|
||||
|
||||
const closer = rotateResultDistance(dollySubjectByWheel(state, -100))
|
||||
const farther = rotateResultDistance(dollySubjectByWheel(state, 100))
|
||||
|
||||
expect(closer).toBeLessThan(state.orbit.distance)
|
||||
expect(farther).toBeGreaterThan(state.orbit.distance)
|
||||
})
|
||||
|
||||
it('orbit: clamps distance to the same bounds as the handle drag', () => {
|
||||
const state = stateWith({
|
||||
mode: 'orbit',
|
||||
orbit: { yaw: 0, pitch: 0, distance: 4 }
|
||||
})
|
||||
|
||||
expect(dollySubjectByWheel(state, -100000)!.nextState.orbit.distance).toBe(
|
||||
0.5
|
||||
)
|
||||
expect(dollySubjectByWheel(state, 100000)!.nextState.orbit.distance).toBe(
|
||||
100
|
||||
)
|
||||
})
|
||||
|
||||
it('look_at: moves the position toward the target, preserving direction', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
const result = dollySubjectByWheel(state, -100)
|
||||
const pos = result!.nextState.lookAt.position
|
||||
|
||||
expect(pos.z).toBeGreaterThan(0)
|
||||
expect(pos.z).toBeLessThan(5)
|
||||
expect(pos.x).toBeCloseTo(0)
|
||||
expect(pos.y).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('look_at: clamps distance to the shared maximum on scroll-out', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
const pos = dollySubjectByWheel(state, 100000)!.nextState.lookAt.position
|
||||
|
||||
expect(Math.hypot(pos.x, pos.y, pos.z)).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('quaternion: moves the position along the camera forward axis', () => {
|
||||
const state = stateWith({
|
||||
mode: 'quaternion',
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 5 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
})
|
||||
const result = dollySubjectByWheel(state, -100)
|
||||
const pos = result!.nextState.quaternion.position
|
||||
|
||||
expect(pos.z).toBeCloseTo(4)
|
||||
expect(pos.x).toBeCloseTo(0)
|
||||
expect(pos.y).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dollySubjectByWheel - orthographic', () => {
|
||||
it('changes zoom rather than position, regardless of mode', () => {
|
||||
const state = stateWith({ mode: 'orbit', cameraType: 'orthographic' })
|
||||
const zoomedIn = dollySubjectByWheel(state, -100)
|
||||
const zoomedOut = dollySubjectByWheel(state, 100)
|
||||
|
||||
expect(zoomedIn!.updates[0].fieldName).toBe('zoom')
|
||||
expect(zoomedIn!.nextState.zoom).toBeGreaterThan(state.zoom)
|
||||
expect(zoomedOut!.nextState.zoom).toBeLessThan(state.zoom)
|
||||
expect(zoomedIn!.nextState.orbit.distance).toBe(state.orbit.distance)
|
||||
})
|
||||
|
||||
it('clamps zoom within bounds', () => {
|
||||
const state = stateWith({ cameraType: 'orthographic', zoom: 1 })
|
||||
|
||||
expect(dollySubjectByWheel(state, -100000)!.nextState.zoom).toBe(100)
|
||||
expect(dollySubjectByWheel(state, 100000)!.nextState.zoom).toBe(0.05)
|
||||
})
|
||||
})
|
||||
|
||||
function rotateResultDistance(
|
||||
result: ReturnType<typeof dollySubjectByWheel>
|
||||
): number {
|
||||
return result!.nextState.orbit.distance
|
||||
}
|
||||
271
src/extensions/core/cameraInfo/lookThroughDragMath.ts
Normal file
271
src/extensions/core/cameraInfo/lookThroughDragMath.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { normalizeQuaternion } from './cameraTransform'
|
||||
import {
|
||||
MAX_DISTANCE,
|
||||
MAX_PITCH,
|
||||
MIN_DISTANCE,
|
||||
MIN_PITCH
|
||||
} from './handles/orbitDragMath'
|
||||
import type { CameraInfoFieldName, CameraInfoState } from './types'
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
const ELEVATION_LIMIT = MAX_PITCH * DEG2RAD
|
||||
const DOLLY_EXP_SENSITIVITY = 0.0015
|
||||
const FREE_DOLLY_UNIT = 0.01
|
||||
const MIN_ZOOM = 0.05
|
||||
const MAX_ZOOM = 100
|
||||
|
||||
interface FieldUpdate {
|
||||
fieldName: CameraInfoFieldName
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface LookThroughResult {
|
||||
nextState: CameraInfoState
|
||||
updates: FieldUpdate[]
|
||||
}
|
||||
|
||||
function directionToSpherical(dir: THREE.Vector3): {
|
||||
azimuth: number
|
||||
elevation: number
|
||||
} {
|
||||
return {
|
||||
azimuth: Math.atan2(dir.x, dir.z),
|
||||
elevation: Math.asin(clamp(dir.y, -1, 1))
|
||||
}
|
||||
}
|
||||
|
||||
function sphericalToDirection(
|
||||
azimuth: number,
|
||||
elevation: number
|
||||
): THREE.Vector3 {
|
||||
const ce = Math.cos(elevation)
|
||||
return new THREE.Vector3(
|
||||
ce * Math.sin(azimuth),
|
||||
Math.sin(elevation),
|
||||
ce * Math.cos(azimuth)
|
||||
)
|
||||
}
|
||||
|
||||
function rotateOrbit(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult {
|
||||
const yaw = state.orbit.yaw + yawDelta * RAD2DEG
|
||||
const pitch = clamp(
|
||||
state.orbit.pitch + pitchDelta * RAD2DEG,
|
||||
MIN_PITCH,
|
||||
MAX_PITCH
|
||||
)
|
||||
return {
|
||||
nextState: { ...state, orbit: { ...state.orbit, yaw, pitch } },
|
||||
updates: [
|
||||
{ fieldName: 'mode.yaw', value: yaw },
|
||||
{ fieldName: 'mode.pitch', value: pitch }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function rotateLookAt(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult | null {
|
||||
const position = new THREE.Vector3(
|
||||
state.lookAt.position.x,
|
||||
state.lookAt.position.y,
|
||||
state.lookAt.position.z
|
||||
)
|
||||
const target = new THREE.Vector3(
|
||||
state.target.x,
|
||||
state.target.y,
|
||||
state.target.z
|
||||
)
|
||||
const offset = target.clone().sub(position)
|
||||
const distance = offset.length()
|
||||
if (distance < 1e-6) return null
|
||||
|
||||
const { azimuth, elevation } = directionToSpherical(
|
||||
offset.clone().normalize()
|
||||
)
|
||||
const nextAzimuth = azimuth + yawDelta
|
||||
const nextElevation = clamp(
|
||||
elevation + pitchDelta,
|
||||
-ELEVATION_LIMIT,
|
||||
ELEVATION_LIMIT
|
||||
)
|
||||
const dir = sphericalToDirection(nextAzimuth, nextElevation)
|
||||
const nextTarget: THREE.Vector3Like = {
|
||||
x: position.x + dir.x * distance,
|
||||
y: position.y + dir.y * distance,
|
||||
z: position.z + dir.z * distance
|
||||
}
|
||||
return {
|
||||
nextState: { ...state, target: nextTarget },
|
||||
updates: [
|
||||
{ fieldName: 'target_x', value: nextTarget.x },
|
||||
{ fieldName: 'target_y', value: nextTarget.y },
|
||||
{ fieldName: 'target_z', value: nextTarget.z }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function rotateQuaternion(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult {
|
||||
const q = normalizeQuaternion(
|
||||
new THREE.Quaternion(
|
||||
state.quaternion.quat.x,
|
||||
state.quaternion.quat.y,
|
||||
state.quaternion.quat.z,
|
||||
state.quaternion.quat.w
|
||||
)
|
||||
)
|
||||
|
||||
const yawQ = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
yawDelta
|
||||
)
|
||||
const pitchQ = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(1, 0, 0),
|
||||
pitchDelta
|
||||
)
|
||||
q.premultiply(yawQ).multiply(pitchQ).normalize()
|
||||
|
||||
const quat = { x: q.x, y: q.y, z: q.z, w: q.w }
|
||||
return {
|
||||
nextState: { ...state, quaternion: { ...state.quaternion, quat } },
|
||||
updates: [
|
||||
{ fieldName: 'mode.quat_x', value: quat.x },
|
||||
{ fieldName: 'mode.quat_y', value: quat.y },
|
||||
{ fieldName: 'mode.quat_z', value: quat.z },
|
||||
{ fieldName: 'mode.quat_w', value: quat.w }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function rotateSubjectByDrag(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult | null {
|
||||
switch (state.mode) {
|
||||
case 'orbit':
|
||||
return rotateOrbit(state, yawDelta, pitchDelta)
|
||||
case 'look_at':
|
||||
return rotateLookAt(state, yawDelta, pitchDelta)
|
||||
case 'quaternion':
|
||||
return rotateQuaternion(state, yawDelta, pitchDelta)
|
||||
}
|
||||
}
|
||||
|
||||
function dollyOrbit(state: CameraInfoState, deltaY: number): LookThroughResult {
|
||||
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
|
||||
const distance = clamp(
|
||||
state.orbit.distance * factor,
|
||||
MIN_DISTANCE,
|
||||
MAX_DISTANCE
|
||||
)
|
||||
return {
|
||||
nextState: { ...state, orbit: { ...state.orbit, distance } },
|
||||
updates: [{ fieldName: 'mode.distance', value: distance }]
|
||||
}
|
||||
}
|
||||
|
||||
function dollyLookAt(
|
||||
state: CameraInfoState,
|
||||
deltaY: number
|
||||
): LookThroughResult | null {
|
||||
const position = new THREE.Vector3(
|
||||
state.lookAt.position.x,
|
||||
state.lookAt.position.y,
|
||||
state.lookAt.position.z
|
||||
)
|
||||
const target = new THREE.Vector3(
|
||||
state.target.x,
|
||||
state.target.y,
|
||||
state.target.z
|
||||
)
|
||||
const offset = position.clone().sub(target)
|
||||
const distance = offset.length()
|
||||
if (distance < 1e-6) return null
|
||||
|
||||
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
|
||||
const nextDistance = clamp(distance * factor, MIN_DISTANCE, MAX_DISTANCE)
|
||||
const next = target
|
||||
.clone()
|
||||
.add(offset.multiplyScalar(nextDistance / distance))
|
||||
const nextPosition: THREE.Vector3Like = { x: next.x, y: next.y, z: next.z }
|
||||
return {
|
||||
nextState: { ...state, lookAt: { position: nextPosition } },
|
||||
updates: [
|
||||
{ fieldName: 'mode.position_x', value: nextPosition.x },
|
||||
{ fieldName: 'mode.position_y', value: nextPosition.y },
|
||||
{ fieldName: 'mode.position_z', value: nextPosition.z }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function dollyQuaternion(
|
||||
state: CameraInfoState,
|
||||
deltaY: number
|
||||
): LookThroughResult {
|
||||
const q = normalizeQuaternion(
|
||||
new THREE.Quaternion(
|
||||
state.quaternion.quat.x,
|
||||
state.quaternion.quat.y,
|
||||
state.quaternion.quat.z,
|
||||
state.quaternion.quat.w
|
||||
)
|
||||
)
|
||||
|
||||
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(q)
|
||||
const step = -deltaY * FREE_DOLLY_UNIT
|
||||
const p = state.quaternion.position
|
||||
const nextPosition: THREE.Vector3Like = {
|
||||
x: p.x + forward.x * step,
|
||||
y: p.y + forward.y * step,
|
||||
z: p.z + forward.z * step
|
||||
}
|
||||
return {
|
||||
nextState: {
|
||||
...state,
|
||||
quaternion: { ...state.quaternion, position: nextPosition }
|
||||
},
|
||||
updates: [
|
||||
{ fieldName: 'mode.position_x', value: nextPosition.x },
|
||||
{ fieldName: 'mode.position_y', value: nextPosition.y },
|
||||
{ fieldName: 'mode.position_z', value: nextPosition.z }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function dollyZoom(state: CameraInfoState, deltaY: number): LookThroughResult {
|
||||
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
|
||||
const zoom = clamp(state.zoom / factor, MIN_ZOOM, MAX_ZOOM)
|
||||
return {
|
||||
nextState: { ...state, zoom },
|
||||
updates: [{ fieldName: 'zoom', value: zoom }]
|
||||
}
|
||||
}
|
||||
|
||||
export function dollySubjectByWheel(
|
||||
state: CameraInfoState,
|
||||
deltaY: number
|
||||
): LookThroughResult | null {
|
||||
if (state.cameraType === 'orthographic') return dollyZoom(state, deltaY)
|
||||
switch (state.mode) {
|
||||
case 'orbit':
|
||||
return dollyOrbit(state, deltaY)
|
||||
case 'look_at':
|
||||
return dollyLookAt(state, deltaY)
|
||||
case 'quaternion':
|
||||
return dollyQuaternion(state, deltaY)
|
||||
}
|
||||
}
|
||||
65
src/extensions/core/cameraInfo/types.ts
Normal file
65
src/extensions/core/cameraInfo/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { QuaternionLike, Vector3Like } from 'three'
|
||||
|
||||
export type CameraInfoMode = 'orbit' | 'look_at' | 'quaternion'
|
||||
|
||||
export type CameraInfoCameraType = 'perspective' | 'orthographic'
|
||||
|
||||
export type CameraInfoFieldName =
|
||||
| 'mode.yaw'
|
||||
| 'mode.pitch'
|
||||
| 'mode.distance'
|
||||
| 'target_x'
|
||||
| 'target_y'
|
||||
| 'target_z'
|
||||
| 'mode.position_x'
|
||||
| 'mode.position_y'
|
||||
| 'mode.position_z'
|
||||
| 'mode.quat_x'
|
||||
| 'mode.quat_y'
|
||||
| 'mode.quat_z'
|
||||
| 'mode.quat_w'
|
||||
| 'roll'
|
||||
| 'fov'
|
||||
| 'zoom'
|
||||
|
||||
interface OrbitInputs {
|
||||
yaw: number
|
||||
pitch: number
|
||||
distance: number
|
||||
}
|
||||
|
||||
interface LookAtInputs {
|
||||
position: Vector3Like
|
||||
}
|
||||
|
||||
interface QuaternionInputs {
|
||||
position: Vector3Like
|
||||
quat: QuaternionLike
|
||||
}
|
||||
|
||||
export interface CameraInfoState {
|
||||
mode: CameraInfoMode
|
||||
target: Vector3Like
|
||||
roll: number
|
||||
fov: number
|
||||
zoom: number
|
||||
cameraType: CameraInfoCameraType
|
||||
orbit: OrbitInputs
|
||||
lookAt: LookAtInputs
|
||||
quaternion: QuaternionInputs
|
||||
}
|
||||
|
||||
export const DEFAULT_CAMERA_INFO_STATE: CameraInfoState = {
|
||||
mode: 'orbit',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
roll: 0,
|
||||
fov: 35,
|
||||
zoom: 1,
|
||||
cameraType: 'perspective',
|
||||
orbit: { yaw: 35, pitch: 30, distance: 4 },
|
||||
lookAt: { position: { x: 4, y: 4, z: 4 } },
|
||||
quaternion: {
|
||||
position: { x: 4, y: 4, z: 4 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
}
|
||||
110
src/extensions/core/cameraInfo/widgetBridge.test.ts
Normal file
110
src/extensions/core/cameraInfo/widgetBridge.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import { readStateFromWidgets, writeWidgetValue } from './widgetBridge'
|
||||
import type { NodeWithWidgets } from './widgetBridge'
|
||||
|
||||
function nodeWithWidgets(
|
||||
values: Record<string, unknown>
|
||||
): NodeWithWidgets & { widgets: { name: string; value: unknown }[] } {
|
||||
return {
|
||||
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
}
|
||||
|
||||
describe('readStateFromWidgets', () => {
|
||||
it('returns defaults when the node has no widgets at all', () => {
|
||||
expect(readStateFromWidgets({})).toEqual(DEFAULT_CAMERA_INFO_STATE)
|
||||
})
|
||||
|
||||
it('reads each widget by name (orbit-mode example)', () => {
|
||||
const node = nodeWithWidgets({
|
||||
mode: 'orbit',
|
||||
target_x: 1,
|
||||
target_y: 2,
|
||||
target_z: 3,
|
||||
roll: 10,
|
||||
fov: 50,
|
||||
zoom: 0.8,
|
||||
camera_type: 'perspective',
|
||||
'mode.yaw': 25,
|
||||
'mode.pitch': 40,
|
||||
'mode.distance': 7
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.mode).toBe('orbit')
|
||||
expect(state.target).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(state.roll).toBe(10)
|
||||
expect(state.fov).toBe(50)
|
||||
expect(state.zoom).toBe(0.8)
|
||||
expect(state.orbit).toEqual({ yaw: 25, pitch: 40, distance: 7 })
|
||||
})
|
||||
|
||||
it('falls back when a widget value has the wrong type', () => {
|
||||
const node = nodeWithWidgets({
|
||||
fov: 'not-a-number',
|
||||
mode: 'invalid-mode',
|
||||
camera_type: 42
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.fov).toBe(DEFAULT_CAMERA_INFO_STATE.fov)
|
||||
expect(state.mode).toBe(DEFAULT_CAMERA_INFO_STATE.mode)
|
||||
expect(state.cameraType).toBe(DEFAULT_CAMERA_INFO_STATE.cameraType)
|
||||
})
|
||||
|
||||
it('rejects non-finite numbers (NaN / Infinity)', () => {
|
||||
const node = nodeWithWidgets({
|
||||
'mode.yaw': Number.NaN,
|
||||
'mode.distance': Number.POSITIVE_INFINITY
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.orbit.yaw).toBe(DEFAULT_CAMERA_INFO_STATE.orbit.yaw)
|
||||
expect(state.orbit.distance).toBe(DEFAULT_CAMERA_INFO_STATE.orbit.distance)
|
||||
})
|
||||
|
||||
it('reads quaternion mode widgets', () => {
|
||||
const node = nodeWithWidgets({
|
||||
mode: 'quaternion',
|
||||
'mode.position_x': 1,
|
||||
'mode.position_y': 2,
|
||||
'mode.position_z': 3,
|
||||
'mode.quat_x': 0,
|
||||
'mode.quat_y': 0,
|
||||
'mode.quat_z': 0,
|
||||
'mode.quat_w': 1
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.mode).toBe('quaternion')
|
||||
expect(state.quaternion.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(state.quaternion.quat).toEqual({ x: 0, y: 0, z: 0, w: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeWidgetValue', () => {
|
||||
it('updates the named widget when the value differs', () => {
|
||||
const node = nodeWithWidgets({ 'mode.yaw': 0 })
|
||||
writeWidgetValue(node, 'mode.yaw', 45)
|
||||
expect(node.widgets.find((w) => w.name === 'mode.yaw')!.value).toBe(45)
|
||||
})
|
||||
|
||||
it('is a no-op when the value already matches', () => {
|
||||
const node = nodeWithWidgets({ fov: 35 })
|
||||
const before = node.widgets[0].value
|
||||
writeWidgetValue(node, 'fov', 35)
|
||||
expect(node.widgets[0].value).toBe(before)
|
||||
})
|
||||
|
||||
it('is a no-op when the widget does not exist', () => {
|
||||
const node = nodeWithWidgets({ fov: 35 })
|
||||
expect(() => writeWidgetValue(node, 'does_not_exist', 1)).not.toThrow()
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
103
src/extensions/core/cameraInfo/widgetBridge.ts
Normal file
103
src/extensions/core/cameraInfo/widgetBridge.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import type {
|
||||
CameraInfoCameraType,
|
||||
CameraInfoMode,
|
||||
CameraInfoState
|
||||
} from './types'
|
||||
|
||||
interface WidgetLike {
|
||||
name: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface NodeWithWidgets {
|
||||
widgets?: WidgetLike[]
|
||||
}
|
||||
|
||||
const VALID_MODES: readonly CameraInfoMode[] = [
|
||||
'orbit',
|
||||
'look_at',
|
||||
'quaternion'
|
||||
]
|
||||
const VALID_CAMERA_TYPES: readonly CameraInfoCameraType[] = [
|
||||
'perspective',
|
||||
'orthographic'
|
||||
]
|
||||
|
||||
function widgetByName(
|
||||
node: NodeWithWidgets,
|
||||
name: string
|
||||
): WidgetLike | undefined {
|
||||
return node.widgets?.find((w) => w.name === name)
|
||||
}
|
||||
|
||||
function num(node: NodeWithWidgets, name: string, fallback: number): number {
|
||||
const v = widgetByName(node, name)?.value
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
||||
}
|
||||
|
||||
function pickMode(node: NodeWithWidgets): CameraInfoMode {
|
||||
const v = widgetByName(node, 'mode')?.value
|
||||
return typeof v === 'string' && (VALID_MODES as readonly string[]).includes(v)
|
||||
? (v as CameraInfoMode)
|
||||
: DEFAULT_CAMERA_INFO_STATE.mode
|
||||
}
|
||||
|
||||
function pickCameraType(node: NodeWithWidgets): CameraInfoCameraType {
|
||||
const v = widgetByName(node, 'camera_type')?.value
|
||||
return typeof v === 'string' &&
|
||||
(VALID_CAMERA_TYPES as readonly string[]).includes(v)
|
||||
? (v as CameraInfoCameraType)
|
||||
: DEFAULT_CAMERA_INFO_STATE.cameraType
|
||||
}
|
||||
|
||||
export function readStateFromWidgets(node: NodeWithWidgets): CameraInfoState {
|
||||
const d = DEFAULT_CAMERA_INFO_STATE
|
||||
return {
|
||||
mode: pickMode(node),
|
||||
target: {
|
||||
x: num(node, 'target_x', d.target.x),
|
||||
y: num(node, 'target_y', d.target.y),
|
||||
z: num(node, 'target_z', d.target.z)
|
||||
},
|
||||
roll: num(node, 'roll', d.roll),
|
||||
fov: num(node, 'fov', d.fov),
|
||||
zoom: num(node, 'zoom', d.zoom),
|
||||
cameraType: pickCameraType(node),
|
||||
orbit: {
|
||||
yaw: num(node, 'mode.yaw', d.orbit.yaw),
|
||||
pitch: num(node, 'mode.pitch', d.orbit.pitch),
|
||||
distance: num(node, 'mode.distance', d.orbit.distance)
|
||||
},
|
||||
lookAt: {
|
||||
position: {
|
||||
x: num(node, 'mode.position_x', d.lookAt.position.x),
|
||||
y: num(node, 'mode.position_y', d.lookAt.position.y),
|
||||
z: num(node, 'mode.position_z', d.lookAt.position.z)
|
||||
}
|
||||
},
|
||||
quaternion: {
|
||||
position: {
|
||||
x: num(node, 'mode.position_x', d.quaternion.position.x),
|
||||
y: num(node, 'mode.position_y', d.quaternion.position.y),
|
||||
z: num(node, 'mode.position_z', d.quaternion.position.z)
|
||||
},
|
||||
quat: {
|
||||
x: num(node, 'mode.quat_x', d.quaternion.quat.x),
|
||||
y: num(node, 'mode.quat_y', d.quaternion.quat.y),
|
||||
z: num(node, 'mode.quat_z', d.quaternion.quat.z),
|
||||
w: num(node, 'mode.quat_w', d.quaternion.quat.w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWidgetValue(
|
||||
node: NodeWithWidgets,
|
||||
name: string,
|
||||
value: number | string
|
||||
): void {
|
||||
const widget = widgetByName(node, name)
|
||||
if (!widget || widget.value === value) return
|
||||
widget.value = value
|
||||
}
|
||||
@@ -266,4 +266,61 @@ describe('CameraManager', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom up', () => {
|
||||
function rolledState(): CameraState {
|
||||
const q = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 0, 1),
|
||||
Math.PI / 2
|
||||
)
|
||||
return {
|
||||
position: new THREE.Vector3(0, 0, 5),
|
||||
target: new THREE.Vector3(0, 0, 0),
|
||||
zoom: 1,
|
||||
cameraType: 'perspective',
|
||||
quaternion: { x: q.x, y: q.y, z: q.z, w: q.w }
|
||||
}
|
||||
}
|
||||
|
||||
it('derives the camera up from an incoming quaternion and flags custom up', () => {
|
||||
manager.setCameraState(rolledState())
|
||||
|
||||
expect(manager.activeCamera.up.x).toBeCloseTo(-1)
|
||||
expect(manager.activeCamera.up.y).toBeCloseTo(0)
|
||||
expect(events.emitEvent).toHaveBeenCalledWith('cameraUpStateChange', {
|
||||
hasCustomUp: true,
|
||||
usingCustomUp: true
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles between the derived up and world Y', () => {
|
||||
manager.setCameraState(rolledState())
|
||||
|
||||
manager.setUseCustomUp(false)
|
||||
expect(manager.activeCamera.up.toArray()).toEqual([0, 1, 0])
|
||||
|
||||
manager.setUseCustomUp(true)
|
||||
expect(manager.activeCamera.up.x).toBeCloseTo(-1)
|
||||
})
|
||||
|
||||
it('setUseCustomUp(true) is a no-op when no custom up was captured', () => {
|
||||
manager.setUseCustomUp(true)
|
||||
|
||||
expect(manager.activeCamera.up.toArray()).toEqual([0, 1, 0])
|
||||
expect(events.emitEvent).not.toHaveBeenCalledWith(
|
||||
'cameraUpStateChange',
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the up vector when the camera type is toggled', () => {
|
||||
manager.setCameraState(rolledState())
|
||||
const up = manager.activeCamera.up.clone()
|
||||
|
||||
manager.toggleCamera('orthographic')
|
||||
|
||||
expect(manager.activeCamera.up.toArray()).toEqual(up.toArray())
|
||||
expect(manager.activeCamera.up.x).toBeCloseTo(-1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
private controls: OrbitControls | null = null
|
||||
|
||||
private customUp: THREE.Vector3 | null = null
|
||||
private usingCustomUp = false
|
||||
|
||||
DEFAULT_DISTANCE = 10
|
||||
DEFAULT_LOOK_AT = 0
|
||||
|
||||
@@ -91,6 +94,7 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
const position = oldCamera.position.clone()
|
||||
const rotation = oldCamera.rotation.clone()
|
||||
const up = oldCamera.up.clone()
|
||||
const target = this.controls?.target.clone() || new THREE.Vector3()
|
||||
|
||||
const oldZoom =
|
||||
@@ -116,6 +120,7 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
this.activeCamera.position.copy(position)
|
||||
this.activeCamera.rotation.copy(rotation)
|
||||
this.activeCamera.up.copy(up)
|
||||
|
||||
if (this.activeCamera instanceof THREE.OrthographicCamera) {
|
||||
this.activeCamera.zoom = oldZoom
|
||||
@@ -171,10 +176,21 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
setCameraState(state: CameraState): void {
|
||||
if (state.cameraType && state.cameraType !== this.getCurrentCameraType()) {
|
||||
this.toggleCamera(state.cameraType)
|
||||
}
|
||||
|
||||
this.activeCamera.position.copy(state.position)
|
||||
|
||||
this.controls?.target.copy(state.target)
|
||||
|
||||
if (
|
||||
state.fov !== undefined &&
|
||||
this.activeCamera instanceof THREE.PerspectiveCamera
|
||||
) {
|
||||
this.activeCamera.fov = state.fov
|
||||
}
|
||||
|
||||
if (this.activeCamera instanceof THREE.OrthographicCamera) {
|
||||
this.activeCamera.zoom = state.zoom
|
||||
this.activeCamera.updateProjectionMatrix()
|
||||
@@ -183,9 +199,40 @@ export class CameraManager implements CameraManagerInterface {
|
||||
this.activeCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
if (state.quaternion) {
|
||||
const q = new THREE.Quaternion(
|
||||
state.quaternion.x,
|
||||
state.quaternion.y,
|
||||
state.quaternion.z,
|
||||
state.quaternion.w
|
||||
)
|
||||
const appliedUp = new THREE.Vector3(0, 1, 0).applyQuaternion(q)
|
||||
this.activeCamera.up.copy(appliedUp)
|
||||
this.customUp = appliedUp.clone()
|
||||
this.usingCustomUp = true
|
||||
this.eventManager.emitEvent('cameraUpStateChange', {
|
||||
hasCustomUp: true,
|
||||
usingCustomUp: true
|
||||
})
|
||||
}
|
||||
|
||||
this.controls?.update()
|
||||
}
|
||||
|
||||
setUseCustomUp(use: boolean): void {
|
||||
if (use && !this.customUp) return
|
||||
if (use === this.usingCustomUp) return
|
||||
const target =
|
||||
use && this.customUp ? this.customUp : new THREE.Vector3(0, 1, 0)
|
||||
this.activeCamera.up.copy(target)
|
||||
this.usingCustomUp = use
|
||||
this.controls?.update()
|
||||
this.eventManager.emitEvent('cameraUpStateChange', {
|
||||
hasCustomUp: this.customUp !== null,
|
||||
usingCustomUp: this.usingCustomUp
|
||||
})
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
const aspect = width / height
|
||||
this.updateAspectRatio(aspect)
|
||||
|
||||
@@ -136,7 +136,9 @@ function makeInstance() {
|
||||
eventManager,
|
||||
adapterRef: { current: null },
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
handleResize: vi.fn(),
|
||||
preRenderCallbacks: [],
|
||||
postRenderCallbacks: []
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,6 +13,7 @@ type CameraStub = {
|
||||
getCurrentCameraType: ReturnType<typeof vi.fn>
|
||||
handleResize: ReturnType<typeof vi.fn>
|
||||
updateAspectRatio: ReturnType<typeof vi.fn>
|
||||
setUseCustomUp: ReturnType<typeof vi.fn>
|
||||
activeCamera: THREE.Camera
|
||||
}
|
||||
|
||||
@@ -48,6 +49,7 @@ function makeViewportInstance() {
|
||||
getCurrentCameraType: vi.fn(() => 'perspective' as const),
|
||||
handleResize: vi.fn(),
|
||||
updateAspectRatio: vi.fn(),
|
||||
setUseCustomUp: vi.fn(),
|
||||
activeCamera: new THREE.PerspectiveCamera()
|
||||
}
|
||||
const sceneManager: SceneStub = {
|
||||
@@ -249,7 +251,6 @@ describe('Viewport3d', () => {
|
||||
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.viewport.getOverlay()).toBe(overlay)
|
||||
})
|
||||
|
||||
it('replacing an overlay detaches and disposes the prior one', () => {
|
||||
@@ -261,18 +262,6 @@ describe('Viewport3d', () => {
|
||||
expect(first.detach).toHaveBeenCalledOnce()
|
||||
expect(first.dispose).toHaveBeenCalledOnce()
|
||||
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
|
||||
expect(ctx.viewport.getOverlay()).toBe(second)
|
||||
})
|
||||
|
||||
it('removeOverlay detaches and disposes the installed overlay', () => {
|
||||
const overlay = makeOverlay()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
|
||||
ctx.viewport.removeOverlay()
|
||||
|
||||
expect(overlay.detach).toHaveBeenCalledOnce()
|
||||
expect(overlay.dispose).toHaveBeenCalledOnce()
|
||||
expect(ctx.viewport.getOverlay()).toBeNull()
|
||||
})
|
||||
|
||||
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
|
||||
@@ -487,4 +476,77 @@ describe('Viewport3d', () => {
|
||||
expect(ctx.forceRender).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('render callback dispatch', () => {
|
||||
interface CallbackAccess {
|
||||
preRenderCallbacks: Array<() => void>
|
||||
postRenderCallbacks: Array<() => void>
|
||||
addPreRenderCallback(cb: () => void): () => void
|
||||
addPostRenderCallback(cb: () => void): () => void
|
||||
runPreRenderCallbacks(): void
|
||||
runPostRenderCallbacks(): void
|
||||
}
|
||||
|
||||
it('does not skip siblings when a post-render callback disposes itself', () => {
|
||||
const vp = ctx.viewport as unknown as CallbackAccess
|
||||
vp.postRenderCallbacks = []
|
||||
const calls: string[] = []
|
||||
|
||||
const disposeA = vp.addPostRenderCallback(() => {
|
||||
calls.push('a')
|
||||
disposeA()
|
||||
})
|
||||
vp.addPostRenderCallback(() => calls.push('b'))
|
||||
|
||||
vp.runPostRenderCallbacks()
|
||||
|
||||
expect(calls).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('runs pre-render callbacks and stops after disposal', () => {
|
||||
const vp = ctx.viewport as unknown as CallbackAccess
|
||||
vp.preRenderCallbacks = []
|
||||
const cb = vi.fn()
|
||||
|
||||
const dispose = vp.addPreRenderCallback(cb)
|
||||
vp.runPreRenderCallbacks()
|
||||
dispose()
|
||||
vp.runPreRenderCallbacks()
|
||||
|
||||
expect(cb).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('dispatches pre -> main scene -> post within a render cycle', () => {
|
||||
const order: string[] = []
|
||||
Object.assign(ctx.viewport, {
|
||||
view: {
|
||||
renderer: { setScissorTest: vi.fn() },
|
||||
beginRender: () => order.push('begin'),
|
||||
blit: vi.fn()
|
||||
},
|
||||
renderMainScene: () => order.push('main'),
|
||||
viewHelperManager: { render: vi.fn() }
|
||||
})
|
||||
const vp = ctx.viewport as unknown as CallbackAccess & {
|
||||
renderView(): void
|
||||
}
|
||||
vp.preRenderCallbacks = []
|
||||
vp.postRenderCallbacks = []
|
||||
vp.addPreRenderCallback(() => order.push('pre'))
|
||||
vp.addPostRenderCallback(() => order.push('post'))
|
||||
|
||||
vp.renderView()
|
||||
|
||||
expect(order).toEqual(['begin', 'pre', 'main', 'post'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setUseCustomUp', () => {
|
||||
it('delegates to the camera manager and forces a render', () => {
|
||||
ctx.viewport.setUseCustomUp(true)
|
||||
|
||||
expect(ctx.cameraManager.setUseCustomUp).toHaveBeenCalledWith(true)
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -183,7 +183,9 @@ export class Viewport3d {
|
||||
|
||||
private renderView(): void {
|
||||
this.view.beginRender()
|
||||
this.runPreRenderCallbacks()
|
||||
this.renderMainScene()
|
||||
this.runPostRenderCallbacks()
|
||||
|
||||
this.renderer.setScissorTest(false)
|
||||
this.viewHelperManager.render(
|
||||
@@ -194,6 +196,33 @@ export class Viewport3d {
|
||||
this.view.blit()
|
||||
}
|
||||
|
||||
private readonly preRenderCallbacks: Array<() => void> = []
|
||||
private readonly postRenderCallbacks: Array<() => void> = []
|
||||
|
||||
addPreRenderCallback(cb: () => void): () => void {
|
||||
this.preRenderCallbacks.push(cb)
|
||||
return () => {
|
||||
const i = this.preRenderCallbacks.indexOf(cb)
|
||||
if (i >= 0) this.preRenderCallbacks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
addPostRenderCallback(cb: () => void): () => void {
|
||||
this.postRenderCallbacks.push(cb)
|
||||
return () => {
|
||||
const i = this.postRenderCallbacks.indexOf(cb)
|
||||
if (i >= 0) this.postRenderCallbacks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private runPreRenderCallbacks(): void {
|
||||
for (const cb of [...this.preRenderCallbacks]) cb()
|
||||
}
|
||||
|
||||
private runPostRenderCallbacks(): void {
|
||||
for (const cb of [...this.postRenderCallbacks]) cb()
|
||||
}
|
||||
|
||||
protected tickPerFrame(delta: number): void {
|
||||
this.overlay?.update?.(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
@@ -353,6 +382,11 @@ export class Viewport3d {
|
||||
return this.cameraManager.getCameraState()
|
||||
}
|
||||
|
||||
setUseCustomUp(use: boolean): void {
|
||||
this.cameraManager.setUseCustomUp(use)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.applyTargetSize(width, height)
|
||||
this.handleResize()
|
||||
|
||||
71
src/extensions/core/load3d/createViewport3d.ts
Normal file
71
src/extensions/core/load3d/createViewport3d.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type * as THREE from 'three'
|
||||
|
||||
import { RendererView } from '@/renderer/three/RendererView'
|
||||
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import { Viewport3d } from './Viewport3d'
|
||||
import type { Viewport3dDeps } from './Viewport3d'
|
||||
import type { Load3DOptions } from './interfaces'
|
||||
|
||||
function buildViewport3dDeps(container: HTMLElement): Viewport3dDeps {
|
||||
const view = new RendererView(container)
|
||||
const renderer = view.renderer
|
||||
const eventManager = new EventManager()
|
||||
|
||||
let cameraManager: CameraManager
|
||||
let controlsManager: ControlsManager
|
||||
|
||||
const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera
|
||||
const getControls = () => controlsManager.controls
|
||||
|
||||
const sceneManager = new SceneManager(
|
||||
view,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
cameraManager = new CameraManager(renderer, eventManager)
|
||||
controlsManager = new ControlsManager(
|
||||
container,
|
||||
cameraManager.activeCamera,
|
||||
eventManager
|
||||
)
|
||||
cameraManager.setControls(controlsManager.controls)
|
||||
|
||||
const lightingManager = new LightingManager(sceneManager.scene, eventManager)
|
||||
const viewHelperManager = new ViewHelperManager(
|
||||
renderer,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
return {
|
||||
view,
|
||||
eventManager,
|
||||
sceneManager,
|
||||
cameraManager,
|
||||
controlsManager,
|
||||
lightingManager,
|
||||
viewHelperManager
|
||||
}
|
||||
}
|
||||
|
||||
export function createViewport3d(
|
||||
container: HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Viewport3d {
|
||||
const viewport = new Viewport3d(
|
||||
container,
|
||||
buildViewport3dDeps(container),
|
||||
options
|
||||
)
|
||||
viewport.start()
|
||||
return viewport
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
hasCustomUp?: boolean
|
||||
useCustomUp?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
@@ -159,6 +161,7 @@ export interface CameraManagerInterface extends BaseManager {
|
||||
setFOV(fov: number): void
|
||||
setCameraState(state: CameraState): void
|
||||
getCameraState(): CameraState
|
||||
setUseCustomUp(use: boolean): void
|
||||
handleResize(width: number, height: number): void
|
||||
setControls(controls: OrbitControls): void
|
||||
}
|
||||
|
||||
@@ -24,3 +24,6 @@ export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
|
||||
|
||||
export const isLoad3dNode = (nodeType: string): boolean =>
|
||||
LOAD3D_ALL_NODES.has(nodeType)
|
||||
|
||||
export const isThreeJsNode = (nodeType: string): boolean =>
|
||||
isLoad3dNode(nodeType) || nodeType === 'CreateCameraInfo'
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import { isLoad3dNode } from './load3d/nodeTypes'
|
||||
import { isLoad3dNode, isThreeJsNode } from './load3d/nodeTypes'
|
||||
|
||||
let load3dExtensionsLoaded = false
|
||||
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
|
||||
@@ -39,7 +39,8 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
import('./load3d'),
|
||||
import('./load3dAdvanced'),
|
||||
import('./load3dPreviewExtensions'),
|
||||
import('./saveMesh')
|
||||
import('./saveMesh'),
|
||||
import('./cameraInfo')
|
||||
])
|
||||
load3dExtensionsLoaded = true
|
||||
return useExtensionStore().enabledExtensions.filter(
|
||||
@@ -58,6 +59,8 @@ useExtensionService().registerExtension({
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
if (!isThreeJsNode(nodeData.name)) return
|
||||
|
||||
if (isLoad3dNode(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
@@ -68,14 +71,14 @@ useExtensionService().registerExtension({
|
||||
modelFile[1].upload_subfolder = '3d'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
// since invokeExtensionsAsync already captured the extensions snapshot
|
||||
// before these new extensions were registered.
|
||||
const newExtensions = await loadLoad3dExtensions()
|
||||
for (const ext of newExtensions) {
|
||||
await ext.beforeRegisterNodeDef?.(nodeType, nodeData, app)
|
||||
}
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
// since invokeExtensionsAsync already captured the extensions snapshot
|
||||
// before these new extensions were registered.
|
||||
const newExtensions = await loadLoad3dExtensions()
|
||||
for (const ext of newExtensions) {
|
||||
await ext.beforeRegisterNodeDef?.(nodeType, nodeData, app)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2052,6 +2052,19 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"useCustomUp": "Restore camera up from input",
|
||||
"useNaturalUp": "Reset camera up to Y",
|
||||
"showGizmos": "Show gizmos",
|
||||
"hideGizmos": "Hide gizmos",
|
||||
"lookThrough": "Camera view",
|
||||
"exitLookThrough": "Exit camera view",
|
||||
"transformGizmo": {
|
||||
"label": "Edit",
|
||||
"none": "None",
|
||||
"target": "Target",
|
||||
"cameraTranslate": "Cam pos",
|
||||
"cameraRotate": "Cam rot"
|
||||
},
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
@@ -2254,6 +2267,7 @@
|
||||
"cannotCreateSubgraph": "Cannot create subgraph",
|
||||
"failedToConvertToSubgraph": "Failed to convert items to subgraph",
|
||||
"failedToInitializeLoad3dViewer": "Failed to initialize 3D Viewer",
|
||||
"failedToInitializeCameraInfoViewer": "Failed to initialize Camera Info viewer. Try reloading the page.",
|
||||
"failedToLoadBackgroundImage": "Failed to load background image",
|
||||
"failedToLoadModel": "Failed to load 3D model",
|
||||
"modelLoadedSuccessfully": "3D model loaded successfully",
|
||||
@@ -2473,6 +2487,11 @@
|
||||
},
|
||||
"credits": {
|
||||
"activity": "Activity",
|
||||
"insufficient": {
|
||||
"memberTitle": "This workspace is out of credits",
|
||||
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
|
||||
"memberCta": "Ok, got it"
|
||||
},
|
||||
"credits": "Credits",
|
||||
"yourCreditBalance": "Your credit balance",
|
||||
"purchaseCredits": "Purchase Credits",
|
||||
@@ -2882,6 +2901,32 @@
|
||||
"updatePassword": "Update Password"
|
||||
},
|
||||
"workspacePanel": {
|
||||
"billingStatus": {
|
||||
"warning": {
|
||||
"title": "Payment declined",
|
||||
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
|
||||
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
|
||||
},
|
||||
"paused": {
|
||||
"title": "Subscription paused",
|
||||
"body": "This workspace's subscription is paused. Update payment to resume.",
|
||||
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
|
||||
},
|
||||
"outOfCredits": {
|
||||
"title": "Out of credits",
|
||||
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
|
||||
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
|
||||
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
|
||||
"addCredits": "Add credits",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"ending": {
|
||||
"title": "Your team plan ends on {date}",
|
||||
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
|
||||
"reactivate": "Reactivate plan"
|
||||
},
|
||||
"updatePayment": "Update payment"
|
||||
},
|
||||
"invite": "Invite",
|
||||
"inviteMember": "Invite member",
|
||||
"inviteLimitReached": "You've reached the maximum of {count} members",
|
||||
@@ -3556,6 +3601,9 @@
|
||||
"dockToTop": "Dock to top",
|
||||
"feedback": "Feedback",
|
||||
"feedbackTooltip": "Feedback",
|
||||
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
|
||||
"freeTierRunsExhausted": "No runs left",
|
||||
"freeTierPartner": "Partner nodes need a paid plan",
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user