mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
6 Commits
codex/data
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e893bf0eba | ||
|
|
5bce9c4874 | ||
|
|
6ac9b653bb | ||
|
|
86bccf8d4c | ||
|
|
b52f6ce764 | ||
|
|
5da3e16f33 |
51
apps/website/e2e/brand.spec.ts
Normal file
51
apps/website/e2e/brand.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../src/data/brandAssets'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
test.describe('Brand portal @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/brand')
|
||||
})
|
||||
|
||||
test('renders each brand guideline section', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Create with ComfyUI' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'One mark, many dimensions.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Every color earns its place.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Precise, never cute.' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Trademark guidelines.' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows all four marks', async ({ page }) => {
|
||||
const logos = page.locator('#logos')
|
||||
for (const name of [
|
||||
'Core Logo',
|
||||
'Logomark',
|
||||
'Icon',
|
||||
'Amplified Logomark'
|
||||
]) {
|
||||
await expect(logos.getByText(name, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('the hero ctas open the gated guidelines and the logo bundle', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'View brand guidelines' })
|
||||
).toHaveAttribute('href', BRAND_GUIDELINES_PDF)
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Download logos' })
|
||||
).toHaveAttribute('href', BRAND_ASSETS_ZIP)
|
||||
})
|
||||
})
|
||||
112
apps/website/e2e/mcp.spec.ts
Normal file
112
apps/website/e2e/mcp.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const MCP_ENDPOINT = 'https://cloud.comfy.org/mcp'
|
||||
|
||||
test.describe('MCP page @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/mcp')
|
||||
})
|
||||
|
||||
test('hero and how-it-works INSTALL MCP CTAs anchor to setup', async ({
|
||||
page
|
||||
}) => {
|
||||
const installLinks = page.getByRole('link', { name: 'INSTALL MCP' })
|
||||
await expect(installLinks).toHaveCount(2)
|
||||
for (const link of await installLinks.all()) {
|
||||
await expect(link).toHaveAttribute('href', '#setup')
|
||||
}
|
||||
})
|
||||
|
||||
test('setup section shows both install options', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
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()
|
||||
})
|
||||
|
||||
test('client tabs swap install instructions', async ({ page }) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
const activePanel = setup.locator('[role="tabpanel"][data-state="active"]')
|
||||
|
||||
// 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(
|
||||
`claude mcp add --transport http comfy-cloud ${MCP_ENDPOINT}`
|
||||
)
|
||||
|
||||
await setup.getByRole('tab', { name: 'Claude Desktop' }).click()
|
||||
await expect(activePanel).toContainText('Add custom connector')
|
||||
|
||||
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 setup.getByRole('tab', { name: 'Codex' }).click()
|
||||
await expect(activePanel).toContainText(
|
||||
`codex mcp add comfy-cloud --url ${MCP_ENDPOINT}`
|
||||
)
|
||||
})
|
||||
|
||||
test('skills plugin link lives in the agent option card', async ({
|
||||
page
|
||||
}) => {
|
||||
const setup = page.locator('#setup')
|
||||
await setup.scrollIntoViewIfNeeded()
|
||||
await expect(
|
||||
setup.getByRole('link', { name: 'View on GitHub' })
|
||||
).toHaveAttribute('href', 'https://github.com/Comfy-Org/comfy-skills')
|
||||
})
|
||||
|
||||
test('capabilities section shows all six tool cards', async ({ page }) => {
|
||||
for (const title of [
|
||||
'Generate anything',
|
||||
'Search the ecosystem',
|
||||
'Run real workflows',
|
||||
'Direct any model',
|
||||
'Generate in batches',
|
||||
'Ship it as an app'
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: title, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('FAQ lists nine questions and autolinks the server URL', async ({
|
||||
page
|
||||
}) => {
|
||||
const triggers = page.locator('[id^="faq-trigger-"]')
|
||||
await triggers.first().scrollIntoViewIfNeeded()
|
||||
await expect(triggers).toHaveCount(9)
|
||||
|
||||
await page.getByRole('button', { name: "What's the server URL?" }).click()
|
||||
await expect(
|
||||
page.getByRole('link', { name: MCP_ENDPOINT, exact: true })
|
||||
).toHaveAttribute('href', MCP_ENDPOINT)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('MCP page zh-CN @smoke', () => {
|
||||
test('setup section renders localized options', async ({ page }) => {
|
||||
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 expect(setup.getByText(MCP_ENDPOINT, { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
4
apps/website/public/icons/comfyicon.svg
Normal file
4
apps/website/public/icons/comfyicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="275" height="275" viewBox="0 0 275 275" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="274.66" height="274.66" rx="63.5555" fill="#211927"/>
|
||||
<path d="M177.456 174.409C177.713 173.538 177.854 172.621 177.854 171.656C177.854 166.313 173.546 161.983 168.232 161.983H125.108C122.791 162.006 120.894 160.124 120.894 157.794C120.894 157.37 120.965 156.97 121.058 156.594L132.67 115.926C133.162 114.137 134.801 112.819 136.72 112.819L180.008 112.772C189.138 112.772 196.84 106.582 199.158 98.1335L205.666 75.4696C205.877 74.6695 205.994 73.7987 205.994 72.9279C205.994 67.6091 201.71 63.3022 196.419 63.3022H144.048C134.965 63.3022 127.286 69.4448 124.921 77.7996L120.52 93.2618C120.005 95.0269 118.389 96.3213 116.47 96.3213H103.898C94.8846 96.3213 87.276 102.346 84.8412 110.607L69.0152 166.172C68.7811 166.996 68.6641 167.89 68.6641 168.784C68.6641 174.127 72.9717 178.457 78.2861 178.457H90.6472C92.9649 178.457 94.8612 180.34 94.8612 182.693C94.8612 183.093 94.8144 183.494 94.6973 183.87L90.3194 199.191C90.1087 200.015 89.9683 200.862 89.9683 201.733C89.9683 207.052 94.2525 211.359 99.5434 211.359L151.938 211.312C161.045 211.312 168.724 205.145 171.065 196.744L177.432 174.433L177.456 174.409Z" fill="#F2FF59"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
@@ -9,6 +9,31 @@ const { faqs } = defineProps<{
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
type AnswerPart = { type: 'text' | 'link'; value: string }
|
||||
|
||||
function parseAnswer(answer: string): AnswerPart[] {
|
||||
const urlPattern = /https?:\/\/[\w\-./?=&#%~:@+,;]+/g
|
||||
const parts: AnswerPart[] = []
|
||||
let lastIndex = 0
|
||||
for (const match of answer.matchAll(urlPattern)) {
|
||||
const start = match.index ?? 0
|
||||
const url = match[0].replace(/[.,;:]+$/, '')
|
||||
if (start > lastIndex) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex, start) })
|
||||
}
|
||||
parts.push({ type: 'link', value: url })
|
||||
lastIndex = start + url.length
|
||||
}
|
||||
if (lastIndex < answer.length) {
|
||||
parts.push({ type: 'text', value: answer.slice(lastIndex) })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
const parsedFaqs = computed(() =>
|
||||
faqs.map((faq) => ({ ...faq, answerParts: parseAnswer(faq.answer) }))
|
||||
)
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
@@ -40,7 +65,7 @@ function toggle(index: number) {
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
v-for="(faq, index) in parsedFaqs"
|
||||
:key="faq.id"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
@@ -83,8 +108,23 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
<p
|
||||
class="text-sm wrap-break-word whitespace-pre-line text-primary-comfy-canvas/70"
|
||||
>
|
||||
<template
|
||||
v-for="(part, partIndex) in faq.answerParts"
|
||||
:key="partIndex"
|
||||
>
|
||||
<a
|
||||
v-if="part.type === 'link'"
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 rounded-sm underline underline-offset-2 transition-opacity hover:opacity-70 focus-visible:ring-2 focus-visible:outline-none"
|
||||
>{{ part.value }}</a
|
||||
>
|
||||
<template v-else>{{ part.value }}</template>
|
||||
</template>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
|
||||
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
type CardAction =
|
||||
| {
|
||||
type: 'link'
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank'
|
||||
icon?: Component
|
||||
variant?: 'default' | 'outline'
|
||||
}
|
||||
| { type: 'code'; value: string }
|
||||
|
||||
export interface FeatureCard {
|
||||
id: string
|
||||
label?: string
|
||||
title: string
|
||||
description: string
|
||||
action?: CardAction
|
||||
}
|
||||
|
||||
type ColumnCount = 2 | 3 | 4
|
||||
|
||||
const {
|
||||
cards,
|
||||
columns = 3,
|
||||
copiedLabel,
|
||||
copyLabel,
|
||||
eyebrow,
|
||||
heading,
|
||||
subtitle
|
||||
} = defineProps<{
|
||||
cards: readonly FeatureCard[]
|
||||
columns?: ColumnCount
|
||||
copiedLabel?: string
|
||||
copyLabel?: string
|
||||
eyebrow?: string
|
||||
heading: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
|
||||
const columnClass: Record<ColumnCount, string> = {
|
||||
2: 'lg:grid-cols-2',
|
||||
3: 'lg:grid-cols-3',
|
||||
4: 'lg:grid-cols-4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<SectionHeader max-width="xl" :label="eyebrow" align="start">
|
||||
{{ heading }}
|
||||
<template v-if="subtitle" #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
|
||||
>
|
||||
<p
|
||||
v-if="card.label"
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ card.label }}
|
||||
</p>
|
||||
<h3
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
|
||||
card.label && 'mt-3'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-3 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="card.action" class="mt-6">
|
||||
<Button
|
||||
v-if="card.action.type === 'link'"
|
||||
as="a"
|
||||
:href="card.action.href"
|
||||
:target="card.action.target"
|
||||
:rel="
|
||||
card.action.target === '_blank'
|
||||
? 'noopener noreferrer'
|
||||
: undefined
|
||||
"
|
||||
:variant="card.action.variant ?? 'outline'"
|
||||
:append-icon="card.action.icon"
|
||||
>
|
||||
{{ card.action.label }}
|
||||
</Button>
|
||||
<CopyableField
|
||||
v-else
|
||||
:value="card.action.value"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import type { VideoTrack } from '../common/VideoPlayer.vue'
|
||||
|
||||
type RowMedia =
|
||||
| { type: 'image'; src: string; alt?: string }
|
||||
| { type: 'image'; src: string; alt?: string; fit?: 'cover' | 'contain' }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
@@ -20,6 +20,7 @@ type RowMedia =
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
@@ -58,7 +59,7 @@ const {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
|
||||
'order-2 flex flex-col justify-center gap-4 p-6 lg:flex-1 lg:p-12',
|
||||
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
|
||||
)
|
||||
"
|
||||
@@ -72,10 +73,11 @@ const {
|
||||
</div>
|
||||
|
||||
<!-- Media: image or video -->
|
||||
<!-- 620/364 and w-155 (620px) match the card media asset dimensions -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'order-1 flex lg:w-1/2',
|
||||
'relative order-1 aspect-620/364 w-full lg:w-155 lg:shrink-0',
|
||||
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
|
||||
)
|
||||
"
|
||||
@@ -86,7 +88,12 @@ const {
|
||||
:alt="row.media.alt ?? row.title"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-4xl object-cover"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full rounded-4xl',
|
||||
row.media.fit === 'contain' ? 'object-contain' : 'object-cover'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<VideoPlayer
|
||||
v-else
|
||||
@@ -99,7 +106,13 @@ const {
|
||||
:loop="row.media.loop"
|
||||
:minimal="row.media.minimal"
|
||||
:hide-controls="row.media.hideControls"
|
||||
class="w-full"
|
||||
:fit="row.media.fit"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 size-full',
|
||||
row.media.fit === 'contain' && 'bg-transparent'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
@@ -85,6 +85,7 @@ const companyColumn: { title: string; links: FooterLink[] } = {
|
||||
links: [
|
||||
{ label: t('footer.about', locale), href: routes.about },
|
||||
{ label: t('nav.careers', locale), href: routes.careers },
|
||||
{ label: t('nav.brand', locale), href: routes.brand },
|
||||
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
|
||||
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
|
||||
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
whenever
|
||||
} from '@vueuse/core'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
@@ -30,7 +31,9 @@ const {
|
||||
autoplay = false,
|
||||
loop = false,
|
||||
minimal = false,
|
||||
hideControls = false
|
||||
hideControls = false,
|
||||
fit = 'cover',
|
||||
class: className
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
src?: string
|
||||
@@ -40,6 +43,8 @@ const {
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
|
||||
@@ -189,7 +194,12 @@ function toggleFullscreen() {
|
||||
<template>
|
||||
<div
|
||||
ref="playerEl"
|
||||
class="relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black"
|
||||
:class="
|
||||
cn(
|
||||
'relative aspect-video overflow-hidden rounded-4xl border border-white/10 bg-black',
|
||||
className
|
||||
)
|
||||
"
|
||||
@pointermove="showControls"
|
||||
@pointerdown="showControls"
|
||||
@focusin="showControls"
|
||||
@@ -197,7 +207,9 @@ function toggleFullscreen() {
|
||||
<video
|
||||
v-if="src"
|
||||
ref="videoEl"
|
||||
class="size-full object-cover"
|
||||
:class="
|
||||
cn('size-full', fit === 'contain' ? 'object-contain' : 'object-cover')
|
||||
"
|
||||
:src
|
||||
:poster
|
||||
:preload="autoplay ? 'auto' : 'metadata'"
|
||||
|
||||
@@ -20,6 +20,8 @@ export const buttonVariants = cva(
|
||||
link: "text-primary-comfy-yellow h-auto justify-start px-0 py-1 text-base uppercase hover:opacity-90 [&_svg:not([class*='size-'])]:size-6",
|
||||
underlineLink:
|
||||
"text-primary-comfy-yellow relative h-auto justify-start px-0 py-1 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-current after:transition-transform after:duration-200 hover:opacity-90 hover:after:scale-x-100 [&_svg:not([class*='size-'])]:size-6",
|
||||
inline:
|
||||
'text-primary-comfy-yellow inline h-auto rounded-none p-0 align-baseline text-sm font-normal tracking-normal whitespace-normal hover:opacity-90 [&>span]:top-0 [&>span]:underline',
|
||||
nav: 'text-primary-warm-white hover:text-primary-comfy-yellow h-auto justify-between px-0 py-1 text-start text-2xl font-medium',
|
||||
navMuted:
|
||||
'hover:text-primary-comfy-yellow h-auto w-full justify-between px-0 py-1 text-start text-2xl font-medium text-primary-comfy-canvas uppercase'
|
||||
|
||||
@@ -21,7 +21,8 @@ const baseRoutes = {
|
||||
affiliateTerms: '/affiliates/terms',
|
||||
contact: '/contact',
|
||||
models: '/p/supported-models',
|
||||
mcp: '/mcp'
|
||||
mcp: '/mcp',
|
||||
brand: '/brand'
|
||||
} as const
|
||||
|
||||
type Routes = typeof baseRoutes
|
||||
@@ -87,6 +88,7 @@ export const externalLinks = {
|
||||
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
|
||||
instagram: 'https://www.instagram.com/comfyui/',
|
||||
linkedin: 'https://www.linkedin.com/company/comfyui',
|
||||
mcpEndpoint: 'https://cloud.comfy.org/mcp',
|
||||
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
|
||||
platform: 'https://platform.comfy.org',
|
||||
platformUsage: 'https://platform.comfy.org/profile/usage',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
import { BRAND_ASSETS_ZIP } from './brandAssets'
|
||||
|
||||
interface AffiliateBrandAsset {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
@@ -7,9 +9,6 @@ interface AffiliateBrandAsset {
|
||||
preview: string
|
||||
}
|
||||
|
||||
const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
|
||||
{
|
||||
id: 'core-logo',
|
||||
|
||||
9
apps/website/src/data/brandAssets.ts
Normal file
9
apps/website/src/data/brandAssets.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Shared brand download URLs served from the media bucket, used by both the
|
||||
// affiliate page and the brand portal.
|
||||
export const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
// Brand guidelines live in Google Drive, shared to Comfy Org only, so Google
|
||||
// enforces the comfy.org sign-in. Opened in a new tab rather than downloaded.
|
||||
export const BRAND_GUIDELINES_PDF =
|
||||
'https://drive.google.com/file/d/1EDt03JTfF_nbbY_H2n67aaUj6k11v3bS/view'
|
||||
91
apps/website/src/data/brandColors.ts
Normal file
91
apps/website/src/data/brandColors.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
interface BrandColor {
|
||||
name: string
|
||||
hex: string
|
||||
rgb: string
|
||||
hsl: string
|
||||
cmyk: string
|
||||
swatchClass: string
|
||||
textClass: string
|
||||
wide?: boolean
|
||||
border?: boolean
|
||||
}
|
||||
|
||||
export const brandColors: readonly BrandColor[] = [
|
||||
{
|
||||
name: 'Comfy Yellow',
|
||||
hex: '#F2FF59',
|
||||
rgb: '242, 255, 89',
|
||||
hsl: '65, 100, 67',
|
||||
cmyk: '5, 0, 65, 0',
|
||||
swatchClass: 'bg-primary-comfy-yellow',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Ink',
|
||||
hex: '#211927',
|
||||
rgb: '33, 25, 39',
|
||||
hsl: '274, 22, 13',
|
||||
cmyk: '15, 36, 0, 85',
|
||||
swatchClass: 'bg-primary-comfy-ink',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Comfy Canvas',
|
||||
hex: '#C2BFB9',
|
||||
rgb: '194, 191, 185',
|
||||
hsl: '40, 7, 74',
|
||||
cmyk: '0, 2, 5, 24',
|
||||
swatchClass: 'bg-primary-comfy-canvas',
|
||||
textClass: 'text-primary-comfy-ink'
|
||||
},
|
||||
{
|
||||
name: 'Comfy Plum',
|
||||
hex: '#49378B',
|
||||
rgb: '73, 55, 139',
|
||||
hsl: '253, 43, 38',
|
||||
cmyk: '47, 60, 0, 45',
|
||||
swatchClass: 'bg-primary-comfy-plum',
|
||||
textClass: 'text-primary-comfy-canvas'
|
||||
},
|
||||
{
|
||||
name: 'Warm White',
|
||||
hex: '#F0EFED',
|
||||
rgb: '240, 239, 237',
|
||||
hsl: '40, 9, 94',
|
||||
cmyk: '0, 0, 1, 6',
|
||||
swatchClass: 'bg-primary-warm-white',
|
||||
textClass: 'text-primary-comfy-ink',
|
||||
wide: true
|
||||
},
|
||||
{
|
||||
name: 'Warm Gray',
|
||||
hex: '#7E7C78',
|
||||
rgb: '126, 124, 120',
|
||||
hsl: '40, 2, 48',
|
||||
cmyk: '0, 2, 5, 51',
|
||||
swatchClass: 'bg-primary-warm-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Cool Gray',
|
||||
hex: '#3C3C3C',
|
||||
rgb: '60, 60, 60',
|
||||
hsl: '0, 0, 24',
|
||||
cmyk: '0, 0, 0, 76',
|
||||
swatchClass: 'bg-secondary-cool-gray',
|
||||
textClass: 'text-primary-warm-white',
|
||||
border: true
|
||||
},
|
||||
{
|
||||
name: 'Mauve',
|
||||
hex: '#4D3762',
|
||||
rgb: '77, 55, 98',
|
||||
hsl: '271, 28, 30',
|
||||
cmyk: '21, 44, 0, 62',
|
||||
swatchClass: 'bg-secondary-mauve',
|
||||
textClass: 'text-primary-warm-white'
|
||||
}
|
||||
] as const
|
||||
@@ -1864,10 +1864,26 @@ const translations = {
|
||||
'zh-CN':
|
||||
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
|
||||
},
|
||||
'mcp.hero.demoPrompt': {
|
||||
'mcp.hero.demoPromptMoodboard': {
|
||||
en: 'turn the brief in this email into a 6-up moodboard',
|
||||
'zh-CN': '把这封邮件里的需求做成六宫格情绪板'
|
||||
},
|
||||
'mcp.hero.demoPromptConcepts': {
|
||||
en: 'sketch three concept frames for the launch page',
|
||||
'zh-CN': '为发布页画三张概念稿'
|
||||
},
|
||||
'mcp.hero.demoPromptKeyart': {
|
||||
en: "match this frame's palette, make the hero key art",
|
||||
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
|
||||
},
|
||||
'mcp.hero.demoPromptPbr': {
|
||||
en: 'make a tileable asphalt PBR material, all 5 maps',
|
||||
'zh-CN': '生成可平铺的沥青 PBR 材质,共 5 张贴图'
|
||||
},
|
||||
'mcp.hero.demoPromptUpscale': {
|
||||
en: 'upscale the neon kaiju shot to 4K',
|
||||
'zh-CN': '把霓虹怪兽画面放大到 4K'
|
||||
},
|
||||
'mcp.hero.viewDocs': {
|
||||
en: 'VIEW DOCS',
|
||||
'zh-CN': '查看文档'
|
||||
@@ -1876,10 +1892,6 @@ const translations = {
|
||||
en: 'INSTALL MCP',
|
||||
'zh-CN': '安装 MCP'
|
||||
},
|
||||
'mcp.hero.runWorkflow': {
|
||||
en: 'RUN A WORKFLOW',
|
||||
'zh-CN': '运行工作流'
|
||||
},
|
||||
'mcp.hero.demoGenerate': {
|
||||
en: 'GENERATE',
|
||||
'zh-CN': '生成'
|
||||
@@ -1897,60 +1909,90 @@ const translations = {
|
||||
'zh-CN': '放大图像'
|
||||
},
|
||||
|
||||
// MCP – SetupStepsSection
|
||||
// MCP – SetupSection
|
||||
'mcp.setup.label': {
|
||||
en: 'GET STARTED',
|
||||
'zh-CN': '快速开始'
|
||||
},
|
||||
'mcp.setup.heading': {
|
||||
en: 'Set up Comfy MCP in three steps',
|
||||
'zh-CN': '三步完成 Comfy MCP 配置'
|
||||
en: 'Set up Comfy MCP',
|
||||
'zh-CN': '配置 Comfy MCP'
|
||||
},
|
||||
'mcp.setup.subtitle': {
|
||||
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
|
||||
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.',
|
||||
'zh-CN':
|
||||
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
'两种接入方式:让你的智能体自动安装,或自行添加服务器。登录一次,ComfyUI 全套工具即可直接在对话中使用。'
|
||||
},
|
||||
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
|
||||
'mcp.setup.step1.title': {
|
||||
'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.step1.command': {
|
||||
'mcp.setup.option1.command': {
|
||||
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
|
||||
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
|
||||
},
|
||||
'mcp.setup.step1.description': {
|
||||
'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.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
|
||||
'mcp.setup.step2.title': {
|
||||
en: 'Or add it by hand',
|
||||
'zh-CN': '或手动添加'
|
||||
'mcp.setup.option2.label': { en: 'OPTION 2', 'zh-CN': '方式二' },
|
||||
'mcp.setup.option2.title': {
|
||||
en: 'Install manually',
|
||||
'zh-CN': '手动安装'
|
||||
},
|
||||
'mcp.setup.step2.description': {
|
||||
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
|
||||
'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.',
|
||||
'zh-CN':
|
||||
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
|
||||
'想手动配置?将此 URL 添加为客户端的自定义连接器或远程 MCP 服务器,然后按提示登录。'
|
||||
},
|
||||
'mcp.setup.step2.cta': {
|
||||
en: 'COMFY CLOUD MCP DOCS',
|
||||
'zh-CN': 'COMFY CLOUD MCP 文档'
|
||||
'mcp.setup.option2.tabsLabel': {
|
||||
en: 'Pick your client',
|
||||
'zh-CN': '选择你的客户端'
|
||||
},
|
||||
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
|
||||
'mcp.setup.step3.title': {
|
||||
en: 'Connect and sign in',
|
||||
'zh-CN': '连接并登录'
|
||||
'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.step3.description': {
|
||||
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
|
||||
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
|
||||
'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':
|
||||
'点击侧边栏的 Customize,进入 Connectors,选择添加自定义连接器,粘贴上方 URL 并登录。'
|
||||
},
|
||||
'mcp.setup.step3.cta': {
|
||||
en: 'COMFY CLOUD SKILLS',
|
||||
'zh-CN': 'COMFY CLOUD 技能'
|
||||
'mcp.setup.clients.cursor.step': {
|
||||
en: 'Add the URL above to ~/.cursor/mcp.json with an X-API-Key header. Create your key at ',
|
||||
'zh-CN':
|
||||
'将上方 URL 添加到 ~/.cursor/mcp.json,并附带 X-API-Key 请求头。在此创建密钥:'
|
||||
},
|
||||
'mcp.setup.clients.cursor.linkLabel': {
|
||||
en: 'platform.comfy.org',
|
||||
'zh-CN': 'platform.comfy.org'
|
||||
},
|
||||
'mcp.setup.clients.codex.step': {
|
||||
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.other.name': {
|
||||
en: 'Other clients',
|
||||
'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 ',
|
||||
'zh-CN':
|
||||
'将上方 URL 添加为远程 MCP 服务器。客户端不支持 OAuth?改用 X-API-Key 请求头。完整教程见'
|
||||
},
|
||||
'mcp.setup.clients.other.linkLabel': {
|
||||
en: 'setup docs',
|
||||
'zh-CN': '设置文档'
|
||||
},
|
||||
'mcp.setup.skillsNote': {
|
||||
en: 'Using Claude Code? The Comfy skills plugin adds ready-made slash commands. ',
|
||||
'zh-CN': '在用 Claude Code?Comfy 技能插件提供现成的斜杠命令。'
|
||||
},
|
||||
'mcp.setup.skillsLink': {
|
||||
en: 'View on GitHub',
|
||||
'zh-CN': '在 GitHub 上查看'
|
||||
},
|
||||
|
||||
// MCP – WhyBuildSection
|
||||
@@ -1971,9 +2013,9 @@ const translations = {
|
||||
'zh-CN': '开放协议,\n任意客户端。'
|
||||
},
|
||||
'mcp.why.1.description': {
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
|
||||
en: 'MCP is an open standard, so any MCP-compatible client can connect. Claude Code, Claude Desktop, and Codex sign in with OAuth; every other agent connects with an API key.',
|
||||
'zh-CN':
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop,更多客户端即将推出。'
|
||||
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。Claude Code、Claude Desktop 和 Codex 通过 OAuth 登录,其他智能体使用 API 密钥连接。'
|
||||
},
|
||||
'mcp.why.2.title': {
|
||||
en: 'The full engine,\nnot a sandbox.',
|
||||
@@ -2037,14 +2079,53 @@ const translations = {
|
||||
'zh-CN': '运行真实工作流'
|
||||
},
|
||||
'mcp.tools.3.description': {
|
||||
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
|
||||
en: 'Submit graphs, track jobs, and pull outputs back. Save and share workflows, reuse a saved one, or open any run on the ComfyUI canvas — the full engine, driven by tool calls.',
|
||||
'zh-CN':
|
||||
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
|
||||
'提交计算图、跟踪任务并取回输出。保存和分享工作流,复用已保存的工作流,或在 ComfyUI 画布上打开任意运行——完整的引擎,由工具调用驱动。'
|
||||
},
|
||||
'mcp.tools.3.alt': {
|
||||
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
|
||||
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
|
||||
},
|
||||
'mcp.tools.4.title': {
|
||||
en: 'Direct any model',
|
||||
'zh-CN': '直接调用任意模型'
|
||||
},
|
||||
'mcp.tools.4.description': {
|
||||
en: 'Kling, Veo, Seedance, Flux, GPT-Image, Nano Banana, and ElevenLabs. Closed partner APIs and open-source models, reached through one set of tools.',
|
||||
'zh-CN':
|
||||
'Kling、Veo、Seedance、Flux、GPT-Image、Nano Banana 和 ElevenLabs。封闭的合作伙伴 API 与开源模型,通过同一套工具即可调用。'
|
||||
},
|
||||
'mcp.tools.4.alt': {
|
||||
en: 'Comfy MCP directing closed partner APIs and open-source models through one set of tools',
|
||||
'zh-CN': 'Comfy MCP 通过同一套工具调用封闭合作伙伴 API 和开源模型'
|
||||
},
|
||||
'mcp.tools.5.title': {
|
||||
en: 'Generate in batches',
|
||||
'zh-CN': '批量生成'
|
||||
},
|
||||
'mcp.tools.5.description': {
|
||||
en: 'Stack a batch on the Queue, track it, and pull back every output. Dozens of runs from a single call.',
|
||||
'zh-CN':
|
||||
'将一批任务加入队列,跟踪进度,并取回每一个输出。一次调用即可完成数十次运行。'
|
||||
},
|
||||
'mcp.tools.5.alt': {
|
||||
en: 'Comfy MCP stacking a batch on the Queue and pulling back every output',
|
||||
'zh-CN': 'Comfy MCP 将一批任务加入队列并取回每个输出'
|
||||
},
|
||||
'mcp.tools.6.title': {
|
||||
en: 'Ship it as an app',
|
||||
'zh-CN': '作为应用发布'
|
||||
},
|
||||
'mcp.tools.6.description': {
|
||||
en: 'Turn any workflow into an app with a shareable URL. Collaborators run it in the browser — only the inputs you expose, nothing to install.',
|
||||
'zh-CN':
|
||||
'将任意工作流变成带可分享链接的应用。协作者在浏览器中运行——只暴露你开放的输入,无需安装任何东西。'
|
||||
},
|
||||
'mcp.tools.6.alt': {
|
||||
en: 'Comfy MCP turning a workflow into a shareable browser app',
|
||||
'zh-CN': 'Comfy MCP 将工作流变成可在浏览器中分享的应用'
|
||||
},
|
||||
|
||||
// MCP – HowItWorksSection
|
||||
'mcp.howItWorks.heading': {
|
||||
@@ -2091,71 +2172,81 @@ const translations = {
|
||||
'zh-CN': '支持哪些客户端?'
|
||||
},
|
||||
'mcp.faq.1.a': {
|
||||
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
|
||||
en: "For Claude Code, Claude Desktop, or Codex, add https://cloud.comfy.org/mcp as a custom connector or remote MCP server in any client, then sign in when prompted.\nFor clients that don't support OAuth, connect with a Comfy API key. Send the docs https://docs.comfy.org/agent-tools/cloud to your agent and it will figure out the installation for you.",
|
||||
'zh-CN':
|
||||
'目前支持 Claude Code 和 Claude Desktop,均通过 OAuth 登录。更多客户端的支持即将推出。'
|
||||
'对于 Claude Code、Claude Desktop 或 Codex,在任意客户端中将 https://cloud.comfy.org/mcp 添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。\n对于不支持 OAuth 的客户端,请使用 Comfy API 密钥连接。将文档 https://docs.comfy.org/agent-tools/cloud 发送给你的智能体,它会为你完成安装。'
|
||||
},
|
||||
'mcp.faq.2.q': {
|
||||
en: "What's the server URL?",
|
||||
'zh-CN': '服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'https://cloud.comfy.org/mcp — add it as a custom connector or remote MCP server in any client, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'https://cloud.comfy.org/mcp——在任意客户端中将它添加为自定义连接器或远程 MCP 服务器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do I need an API key?',
|
||||
'zh-CN': '我需要 API 密钥吗?'
|
||||
},
|
||||
'mcp.faq.2.a': {
|
||||
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
|
||||
'zh-CN':
|
||||
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
|
||||
},
|
||||
'mcp.faq.3.q': {
|
||||
en: 'Do the slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.3.a': {
|
||||
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
|
||||
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.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
|
||||
'Claude Code、Claude Desktop 和 Codex 不需要。Cursor、Hermes 和 OpenClaw 目前需要 Comfy API 密钥。只需复制 https://docs.comfy.org/agent-tools/cloud,你的智能体就会为你完成安装。'
|
||||
},
|
||||
'mcp.faq.4.q': {
|
||||
en: "The sign-in didn't open a browser.",
|
||||
'zh-CN': '登录时没有打开浏览器。'
|
||||
en: 'Does it cost anything?',
|
||||
'zh-CN': '需要付费吗?'
|
||||
},
|
||||
'mcp.faq.4.a': {
|
||||
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
|
||||
en: "Connecting is free with a Comfy account, and searching models, nodes, and templates doesn't cost credits. Running a generation uses Comfy Cloud credits and needs a subscription or credit balance. Your agent confirms with you before it spends.",
|
||||
'zh-CN':
|
||||
'在 Claude Code 中,运行 /mcp,选择 comfy-cloud,然后选择 Authenticate(授权)。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
|
||||
'使用 Comfy 账户连接是免费的,搜索模型、节点和模板也不消耗积分。运行生成会使用 Comfy Cloud 积分,需要订阅或积分余额。智能体在消费前会先与你确认。'
|
||||
},
|
||||
'mcp.faq.5.q': {
|
||||
en: 'How do I connect in Claude Code?',
|
||||
'zh-CN': '如何在 Claude Code 中连接?'
|
||||
en: 'Can I use it with my local ComfyUI?',
|
||||
'zh-CN': '可以配合我的本地 ComfyUI 使用吗?'
|
||||
},
|
||||
'mcp.faq.5.a': {
|
||||
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
|
||||
en: 'Coming soon. Today, to drive a local ComfyUI, you can use comfy-cli: https://github.com/Comfy-Org/comfy-cli',
|
||||
'zh-CN':
|
||||
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate(授权)。一步即可添加连接和斜杠命令。'
|
||||
'即将推出。目前,若要操作本地 ComfyUI,你可以使用 comfy-cli:https://github.com/Comfy-Org/comfy-cli'
|
||||
},
|
||||
'mcp.faq.6.q': {
|
||||
en: "What's the server URL for Claude Desktop?",
|
||||
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
|
||||
},
|
||||
'mcp.faq.6.a': {
|
||||
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
|
||||
'zh-CN':
|
||||
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'What can my agent do once connected?',
|
||||
'zh-CN': '连接后我的智能体能做什么?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
|
||||
'mcp.faq.6.a': {
|
||||
en: "• Generate images, video, audio, and 3D — including all open-source workflows and partner models like Seedance, GPT-Image, Nano Banana, and Kling\n• Build, edit, and run workflows; save and re-run workflows\n• Run and read in large batches\n• Search models, nodes, and template workflows\n• Read and execute shared workflow URLs\n• Upload and download assets for you\n\nEverything is now in natural language. No nodes, no downloads, no GPU, no node graphs if you don't want them.",
|
||||
'zh-CN':
|
||||
'生成图像、视频、音频和 3D;搜索模型、节点和模板;并运行 ComfyUI 工作流——全部在对话中完成。'
|
||||
'• 生成图像、视频、音频和 3D——包括所有开源工作流以及 Seedance、GPT-Image、Nano Banana 和 Kling 等合作伙伴模型\n• 构建、编辑和运行工作流;保存并重新运行工作流\n• 大批量运行和读取\n• 搜索模型、节点和模板工作流\n• 读取并执行分享的工作流链接\n• 为你上传和下载资产\n\n现在一切都用自然语言完成。如果你愿意,无需节点、无需下载、无需 GPU、无需节点图。'
|
||||
},
|
||||
'mcp.faq.7.q': {
|
||||
en: 'Where do my outputs go?',
|
||||
'zh-CN': '我的输出会保存到哪里?'
|
||||
},
|
||||
'mcp.faq.7.a': {
|
||||
en: 'Into your Comfy Cloud asset library, so you can reuse, remix, and share them — and open any run on the canvas to keep editing. You can also ask your agent to download the assets locally for you.',
|
||||
'zh-CN':
|
||||
'保存到你的 Comfy Cloud 资产库,你可以复用、二次创作和分享——还能在画布上打开任意运行继续编辑。你也可以让智能体把资产下载到本地。'
|
||||
},
|
||||
'mcp.faq.8.q': {
|
||||
en: 'Do slash commands work in Claude Desktop?',
|
||||
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'No. They ship with the Claude Code comfy-cloud plugin. Desktop connects to the same MCP server, so every tool works; just ask in plain language.',
|
||||
'zh-CN':
|
||||
'不可以。斜杠命令随 Claude Code 的 comfy-cloud 插件一起提供。Claude Desktop 连接的是同一个 MCP 服务器,因此所有工具都能使用;直接用自然语言提问即可。'
|
||||
},
|
||||
'mcp.faq.9.q': {
|
||||
en: 'Is it generally available?',
|
||||
'zh-CN': '现已正式发布了吗?'
|
||||
},
|
||||
'mcp.faq.8.a': {
|
||||
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
|
||||
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
|
||||
'mcp.faq.9.a': {
|
||||
en: 'Yes. Comfy Cloud MCP is in open beta and available to everyone with a Comfy account.',
|
||||
'zh-CN':
|
||||
'是的。Comfy Cloud MCP 目前处于公开测试阶段,任何拥有 Comfy 账户的人都可以使用。'
|
||||
},
|
||||
|
||||
// SiteNav
|
||||
@@ -2181,6 +2272,7 @@ const translations = {
|
||||
'nav.youtube': { en: 'YouTube', 'zh-CN': 'YouTube' },
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.brand': { en: 'Brand', 'zh-CN': '品牌' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.launches': { en: 'Launches', 'zh-CN': '发布' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
@@ -4446,6 +4538,161 @@ const translations = {
|
||||
'launches.section.title': {
|
||||
en: 'Latest Launches',
|
||||
'zh-CN': '最新发布'
|
||||
},
|
||||
|
||||
// Brand Portal page (/brand)
|
||||
'brand.page.title': {
|
||||
en: 'Brand — Comfy',
|
||||
'zh-CN': '品牌 — Comfy'
|
||||
},
|
||||
'brand.page.description': {
|
||||
en: 'The Comfy brand portal: logos, color, typography, and voice. Everything you need to build something that looks and sounds like Comfy.',
|
||||
'zh-CN':
|
||||
'Comfy 品牌门户:标志、色彩、字体与语调。打造与 Comfy 观感一致、表达一致所需的一切。'
|
||||
},
|
||||
'brand.hero.label': {
|
||||
en: 'Brand Portal',
|
||||
'zh-CN': '品牌门户'
|
||||
},
|
||||
'brand.hero.heading': {
|
||||
en: 'Create with ComfyUI',
|
||||
'zh-CN': '用 ComfyUI 创作'
|
||||
},
|
||||
'brand.hero.subheading': {
|
||||
en: 'Logo, color, type, and voice. Everything you need to build something that looks and sounds like us.',
|
||||
'zh-CN': '标志、色彩、字体与语调。打造与我们观感一致、表达一致所需的一切。'
|
||||
},
|
||||
'brand.hero.viewGuidelines': {
|
||||
en: 'View brand guidelines',
|
||||
'zh-CN': '查看品牌规范'
|
||||
},
|
||||
'brand.hero.downloadLogos': {
|
||||
en: 'Download logos',
|
||||
'zh-CN': '下载标志'
|
||||
},
|
||||
'brand.logos.heading': {
|
||||
en: 'One mark, many dimensions.',
|
||||
'zh-CN': '一个标志,多种维度。'
|
||||
},
|
||||
'brand.logos.subheading': {
|
||||
en: 'Logos come in light and dark options. Use as provided. Do not distort, recolor, or outline. Make sure the logo is legible against its background.',
|
||||
'zh-CN':
|
||||
'标志提供浅色和深色两种版本。请按原样使用,不要变形、改色或描边。确保标志在其背景上清晰可辨。'
|
||||
},
|
||||
'brand.colors.heading': {
|
||||
en: 'Every color earns its place.',
|
||||
'zh-CN': '每种颜色都各得其所。'
|
||||
},
|
||||
'brand.colors.subheading': {
|
||||
en: 'Our color palette helps build brand recognition. When people think of Comfy, we want them to associate it with the following colors.',
|
||||
'zh-CN':
|
||||
'我们的调色板有助于建立品牌辨识度。当人们想到 Comfy 时,我们希望他们联想到以下这些颜色。'
|
||||
},
|
||||
'brand.colors.copy': {
|
||||
en: 'Copy',
|
||||
'zh-CN': '复制'
|
||||
},
|
||||
'brand.colors.copied': {
|
||||
en: 'Copied',
|
||||
'zh-CN': '已复制'
|
||||
},
|
||||
'brand.voice.heading': {
|
||||
en: 'Precise, never cute.',
|
||||
'zh-CN': '精准,绝不卖弄。'
|
||||
},
|
||||
'brand.voice.direct.title': {
|
||||
en: 'Direct',
|
||||
'zh-CN': '直接'
|
||||
},
|
||||
'brand.voice.direct.body': {
|
||||
en: 'We state things. We don’t hedge, qualify, or suggest. Short sentences. Active voice. One idea at a time.',
|
||||
'zh-CN':
|
||||
'我们直陈其事。不含糊、不设限、不暗示。短句。主动语态。一次只讲一个观点。'
|
||||
},
|
||||
'brand.voice.precise.title': {
|
||||
en: 'Precise',
|
||||
'zh-CN': '精准'
|
||||
},
|
||||
'brand.voice.precise.body': {
|
||||
en: 'We use the real names for things. Nodes, samplers, seeds, checkpoints. We don’t talk around the product or reach for metaphor when the technical term is already good.',
|
||||
'zh-CN':
|
||||
'我们直呼其名:nodes、samplers、seeds、checkpoints。当技术术语已经足够贴切时,我们不绕弯子,也不借用比喻。'
|
||||
},
|
||||
'brand.voice.human.title': {
|
||||
en: 'Human-first',
|
||||
'zh-CN': '以人为先'
|
||||
},
|
||||
'brand.voice.human.body': {
|
||||
en: 'The human creates. Comfy makes every step visible. We never write as though the AI is doing the work.',
|
||||
'zh-CN':
|
||||
'创作的是人。Comfy 让每一步都清晰可见。我们绝不把功劳写成是 AI 完成的。'
|
||||
},
|
||||
'brand.voice.antihype.title': {
|
||||
en: 'Anti-hype',
|
||||
'zh-CN': '拒绝浮夸'
|
||||
},
|
||||
'brand.voice.antihype.body': {
|
||||
en: 'We don’t write “stunning,” “revolutionary,” or “effortless.” We don’t promise magic. Our tagline says exactly what we mean: Method, not magic.',
|
||||
'zh-CN':
|
||||
'我们不写“惊艳”“革命性”或“毫不费力”。我们不承诺魔法。我们的口号恰如其分:方法,而非魔法。'
|
||||
},
|
||||
'brand.voice.doLabel': {
|
||||
en: 'Do',
|
||||
'zh-CN': '推荐'
|
||||
},
|
||||
'brand.voice.dontLabel': {
|
||||
en: 'Don’t',
|
||||
'zh-CN': '避免'
|
||||
},
|
||||
'brand.voice.do.0': {
|
||||
en: 'Route your prompt through a ControlNet. Wire the output to the VAE decode.',
|
||||
'zh-CN': '让你的 prompt 经过 ControlNet,再将输出连接到 VAE decode。'
|
||||
},
|
||||
'brand.voice.do.1': {
|
||||
en: 'Comfy runs on your hardware. Nothing leaves your machine.',
|
||||
'zh-CN': 'Comfy 在你自己的硬件上运行。任何数据都不会离开你的机器。'
|
||||
},
|
||||
'brand.voice.dont.0': {
|
||||
en: 'Simply connect your AI blocks and watch the magic happen!',
|
||||
'zh-CN': '只需连接你的 AI 模块,见证奇迹的发生!'
|
||||
},
|
||||
'brand.voice.dont.1': {
|
||||
en: 'Oops! Something went wrong. Please try again later.',
|
||||
'zh-CN': '哎呀!出了点问题,请稍后再试。'
|
||||
},
|
||||
'brand.trademark.heading': {
|
||||
en: 'Trademark guidelines.',
|
||||
'zh-CN': '商标使用规范。'
|
||||
},
|
||||
'brand.trademark.body1': {
|
||||
en: 'Comfy and ComfyUI are trademarks of Comfy Org. You’re welcome to reference them in content that accurately describes your work with our platform. Tutorials, reviews, integrations, and affiliate content all qualify.',
|
||||
'zh-CN':
|
||||
'Comfy 和 ComfyUI 是 Comfy Org 的商标。欢迎在准确描述你与我们平台相关工作的内容中引用它们。教程、评测、集成以及联盟内容均可。'
|
||||
},
|
||||
'brand.trademark.body2': {
|
||||
en: 'A few rules: don’t modify the logo, don’t use the Comfy name in your own product or company name, and don’t present your content in a way that implies official endorsement or partnership beyond what’s been agreed.',
|
||||
'zh-CN':
|
||||
'几条规则:不要修改标志,不要在你自己的产品或公司名称中使用 Comfy 这一名称,也不要以暗示官方认可或合作关系(超出双方已达成的约定)的方式呈现你的内容。'
|
||||
},
|
||||
'brand.trademark.body3': {
|
||||
en: 'For permissions outside these guidelines,',
|
||||
'zh-CN': '如需本规范之外的授权,请'
|
||||
},
|
||||
'brand.trademark.contact': {
|
||||
en: 'Contact Us',
|
||||
'zh-CN': '联系我们'
|
||||
},
|
||||
'brand.questions.heading': {
|
||||
en: 'Questions?',
|
||||
'zh-CN': '有疑问?'
|
||||
},
|
||||
'brand.questions.body': {
|
||||
en: 'For press, partnerships, or anything outside these guidelines,',
|
||||
'zh-CN': '如涉及媒体、合作,或本规范未涵盖的任何事宜,请'
|
||||
},
|
||||
'brand.questions.contact': {
|
||||
en: 'Contact Us',
|
||||
'zh-CN': '联系我们'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
28
apps/website/src/pages/brand.astro
Normal file
28
apps/website/src/pages/brand.astro
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
|
||||
const locale = 'en' as const
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', locale)}
|
||||
description={t('brand.page.description', locale)}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection />
|
||||
<BrandLogosSection />
|
||||
<BrandColorSection client:visible />
|
||||
<BrandVoiceSection />
|
||||
<BrandTrademarkSection />
|
||||
<BrandQuestionsSection />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../utils/jsonLd'
|
||||
|
||||
@@ -30,7 +31,7 @@ const { siteUrl, locale } = pageContext(
|
||||
{ name: t('breadcrumb.home', locale), url: absoluteUrl(Astro.site, '/') },
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui desktop', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
|
||||
26
apps/website/src/pages/zh-CN/brand.astro
Normal file
26
apps/website/src/pages/zh-CN/brand.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import BrandBackground from '../../templates/brand/BrandBackground.vue'
|
||||
import BrandHeroSection from '../../templates/brand/BrandHeroSection.vue'
|
||||
import BrandLogosSection from '../../templates/brand/BrandLogosSection.vue'
|
||||
import BrandColorSection from '../../templates/brand/BrandColorSection.vue'
|
||||
import BrandVoiceSection from '../../templates/brand/BrandVoiceSection.vue'
|
||||
import BrandTrademarkSection from '../../templates/brand/BrandTrademarkSection.vue'
|
||||
import BrandQuestionsSection from '../../templates/brand/BrandQuestionsSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('brand.page.title', 'zh-CN')}
|
||||
description={t('brand.page.description', 'zh-CN')}
|
||||
>
|
||||
<div class="relative">
|
||||
<BrandBackground client:idle />
|
||||
<BrandHeroSection locale="zh-CN" />
|
||||
<BrandLogosSection locale="zh-CN" />
|
||||
<BrandColorSection client:visible locale="zh-CN" />
|
||||
<BrandVoiceSection locale="zh-CN" />
|
||||
<BrandTrademarkSection locale="zh-CN" />
|
||||
<BrandQuestionsSection locale="zh-CN" />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
absoluteUrl,
|
||||
comfyUiApplicationNode,
|
||||
comfyUiSoftwareId,
|
||||
comfyUiSourceCodeNode,
|
||||
pageContext,
|
||||
} from '../../utils/jsonLd'
|
||||
|
||||
@@ -33,7 +34,7 @@ const { siteUrl, locale } = pageContext(
|
||||
},
|
||||
{ name: t('breadcrumb.download', locale) },
|
||||
]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl)]}
|
||||
extraJsonLd={[comfyUiApplicationNode(siteUrl), comfyUiSourceCodeNode(siteUrl)]}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
|
||||
112
apps/website/src/templates/brand/BrandBackground.vue
Normal file
112
apps/website/src/templates/brand/BrandBackground.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useEventListener,
|
||||
useIntersectionObserver,
|
||||
useRafFn
|
||||
} from '@vueuse/core'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
import { prefersReducedMotion } from '../../composables/useReducedMotion'
|
||||
|
||||
interface Node {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
}
|
||||
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
let dpr =
|
||||
typeof window !== 'undefined' ? Math.min(window.devicePixelRatio || 1, 2) : 1
|
||||
|
||||
const nodes: Node[] = Array.from({ length: 14 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
vx: (Math.random() - 0.5) * 0.0005,
|
||||
vy: (Math.random() - 0.5) * 0.0005
|
||||
}))
|
||||
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
|
||||
function draw() {
|
||||
const el = canvasEl.value
|
||||
if (!el || !ctx) return
|
||||
const w = el.width
|
||||
const h = el.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (const n of nodes) {
|
||||
n.x += n.vx
|
||||
n.y += n.vy
|
||||
if (n.x < 0 || n.x > 1) n.vx *= -1
|
||||
if (n.y < 0 || n.y > 1) n.vy *= -1
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(242, 255, 89, 0.18)'
|
||||
ctx.lineWidth = dpr
|
||||
const max = Math.min(w, h) * 0.22
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i]
|
||||
const b = nodes[j]
|
||||
const dx = (a.x - b.x) * w
|
||||
const dy = (a.y - b.y) * h
|
||||
const d = Math.hypot(dx, dy)
|
||||
if (d < max) {
|
||||
ctx.globalAlpha = 1 - d / max
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(a.x * w, a.y * h)
|
||||
const mx = ((a.x + b.x) / 2) * w
|
||||
ctx.bezierCurveTo(mx, a.y * h, mx, b.y * h, b.x * w, b.y * h)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.fillStyle = 'rgba(242, 255, 89, 0.9)'
|
||||
for (const n of nodes) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(n.x * w, n.y * h, 2.5 * dpr, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// Resizing clears the canvas bitmap, so repaint immediately afterwards to keep
|
||||
// the field visible even when the RAF loop is paused (off screen or
|
||||
// reduced-motion). Also refresh dpr in case the window moved to another display.
|
||||
function resize() {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
el.width = el.offsetWidth * dpr
|
||||
el.height = el.offsetHeight * dpr
|
||||
draw()
|
||||
}
|
||||
|
||||
const { pause, resume } = useRafFn(draw, { immediate: false })
|
||||
|
||||
// Only animate while the field is on screen, and honour reduced-motion by
|
||||
// painting a single static frame instead of looping.
|
||||
useIntersectionObserver(canvasEl, ([entry]) => {
|
||||
if (prefersReducedMotion()) return
|
||||
if (entry?.isIntersecting) resume()
|
||||
else pause()
|
||||
})
|
||||
|
||||
useEventListener('resize', resize)
|
||||
|
||||
onMounted(() => {
|
||||
ctx = canvasEl.value?.getContext('2d') ?? null
|
||||
resize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-x-0 top-0 -z-10 h-screen w-full"
|
||||
/>
|
||||
</template>
|
||||
93
apps/website/src/templates/brand/BrandColorSection.vue
Normal file
93
apps/website/src/templates/brand/BrandColorSection.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { brandColors } from '../../data/brandColors'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const specRows = ['hex', 'rgb', 'hsl', 'cmyk'] as const
|
||||
|
||||
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
|
||||
const copiedHex = ref<string | null>(null)
|
||||
const copiedValue = ref('')
|
||||
|
||||
function copyValue(hex: string, value: string) {
|
||||
copiedHex.value = hex
|
||||
copiedValue.value = value
|
||||
void copy(value)
|
||||
}
|
||||
|
||||
function isCardCopied(hex: string) {
|
||||
return copied.value && copiedHex.value === hex
|
||||
}
|
||||
|
||||
const liveMessage = computed(() =>
|
||||
copied.value ? `${t('brand.colors.copied', locale)} ${copiedValue.value}` : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.colors.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
|
||||
{{ t('brand.colors.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<span class="sr-only" aria-live="polite">{{ liveMessage }}</span>
|
||||
|
||||
<ul class="mt-10 grid grid-cols-2 gap-4 lg:grid-cols-5">
|
||||
<li
|
||||
v-for="color in brandColors"
|
||||
:key="color.hex"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-[123px] cursor-pointer flex-col rounded-[30px] p-6',
|
||||
color.swatchClass,
|
||||
color.textClass,
|
||||
color.wide && 'lg:col-span-2',
|
||||
color.border && 'border-primary-warm-gray border-[0.783px]'
|
||||
)
|
||||
"
|
||||
@click="copyValue(color.hex, color.hex)"
|
||||
>
|
||||
<div
|
||||
v-if="isCardCopied(color.hex)"
|
||||
class="flex flex-1 items-center justify-center text-center text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ t('brand.colors.copied', locale) }} {{ copiedValue }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<span class="text-xs font-semibold">{{ color.name }}</span>
|
||||
<dl
|
||||
class="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 text-xs leading-[1.4]"
|
||||
>
|
||||
<template v-for="row in specRows" :key="row">
|
||||
<dt class="uppercase opacity-50">{{ row }}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="`${t('brand.colors.copy', locale)} ${row} ${color[row]}`"
|
||||
class="cursor-pointer text-left hover:underline"
|
||||
@click.stop="copyValue(color.hex, color[row])"
|
||||
>
|
||||
{{ color[row] }}
|
||||
</button>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
55
apps/website/src/templates/brand/BrandHeroSection.vue
Normal file
55
apps/website/src/templates/brand/BrandHeroSection.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { BRAND_ASSETS_ZIP, BRAND_GUIDELINES_PDF } from '../../data/brandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto px-6 pt-4 pb-10 text-center lg:px-20 lg:pb-12"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-yellow text-sm font-extrabold tracking-[0.7px] uppercase"
|
||||
>
|
||||
{{ t('brand.hero.label', locale) }}
|
||||
</p>
|
||||
<h1
|
||||
class="lg:text-6.5xl mx-auto mt-6 max-w-4xl text-4xl leading-[1.3] font-light tracking-[-0.03em] text-primary-comfy-canvas md:text-5xl"
|
||||
>
|
||||
{{ t('brand.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="mx-auto mt-6 max-w-2xl text-[17px] leading-[1.6] font-light text-primary-comfy-canvas/80"
|
||||
>
|
||||
{{ t('brand.hero.subheading', locale) }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-10 flex flex-col items-center justify-center gap-3 sm:flex-row"
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
:href="BRAND_GUIDELINES_PDF"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
|
||||
>
|
||||
{{ t('brand.hero.viewGuidelines', locale) }}
|
||||
</Button>
|
||||
<Button
|
||||
as="a"
|
||||
:href="BRAND_ASSETS_ZIP"
|
||||
download
|
||||
variant="outline"
|
||||
class="h-12 w-full px-5 text-sm font-extrabold sm:w-auto"
|
||||
>
|
||||
{{ t('brand.hero.downloadLogos', locale) }}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
58
apps/website/src/templates/brand/BrandLogosSection.vue
Normal file
58
apps/website/src/templates/brand/BrandLogosSection.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const assets = affiliateBrandAssets.map((asset) =>
|
||||
asset.id === 'icon' ? { ...asset, preview: '/icons/comfyicon.svg' } : asset
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="logos" class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.logos.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="text-primary-warm-gray mt-4 max-w-2xl text-sm leading-[1.45]">
|
||||
{{ t('brand.logos.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
|
||||
<ul class="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<li
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
class="flex min-h-60 flex-col rounded-[30px] border-[1.5px] border-white/8 lg:min-h-[285px]"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="asset.title[locale]"
|
||||
:class="
|
||||
cn(
|
||||
'object-contain',
|
||||
asset.id === 'icon'
|
||||
? 'size-24 rounded-[23%] border border-white/10'
|
||||
: 'max-h-24 max-w-[75%]'
|
||||
)
|
||||
"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="pb-8 text-center text-[21px] font-medium tracking-[1.05px] text-primary-comfy-canvas"
|
||||
>
|
||||
{{ asset.title[locale] }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
33
apps/website/src/templates/brand/BrandQuestionsSection.vue
Normal file
33
apps/website/src/templates/brand/BrandQuestionsSection.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { externalLinks } from '../../config/routes.ts'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto px-6 pt-10 pb-24 lg:px-20 lg:pt-12 lg:pb-32"
|
||||
>
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.questions.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<p class="text-primary-warm-gray mt-6 max-w-2xl text-sm leading-[1.6]">
|
||||
{{ t('brand.questions.body', locale) }}
|
||||
<Button
|
||||
as="a"
|
||||
variant="inline"
|
||||
:href="externalLinks.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('brand.questions.contact', locale) }}
|
||||
</Button>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
37
apps/website/src/templates/brand/BrandTrademarkSection.vue
Normal file
37
apps/website/src/templates/brand/BrandTrademarkSection.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import Button from '../../components/ui/button/Button.vue'
|
||||
import { externalLinks } from '../../config/routes.ts'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.trademark.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<div
|
||||
class="text-primary-warm-gray mt-6 flex max-w-4xl flex-col gap-4 text-sm leading-[1.6]"
|
||||
>
|
||||
<p>{{ t('brand.trademark.body1', locale) }}</p>
|
||||
<p>{{ t('brand.trademark.body2', locale) }}</p>
|
||||
<p>
|
||||
{{ t('brand.trademark.body3', locale) }}
|
||||
<Button
|
||||
as="a"
|
||||
variant="inline"
|
||||
:href="externalLinks.support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ t('brand.trademark.contact', locale) }}
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
100
apps/website/src/templates/brand/BrandVoiceSection.vue
Normal file
100
apps/website/src/templates/brand/BrandVoiceSection.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const principles = [
|
||||
{
|
||||
title: t('brand.voice.direct.title', locale),
|
||||
body: t('brand.voice.direct.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.precise.title', locale),
|
||||
body: t('brand.voice.precise.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.human.title', locale),
|
||||
body: t('brand.voice.human.body', locale)
|
||||
},
|
||||
{
|
||||
title: t('brand.voice.antihype.title', locale),
|
||||
body: t('brand.voice.antihype.body', locale)
|
||||
}
|
||||
]
|
||||
|
||||
const doExamples = [
|
||||
t('brand.voice.do.0', locale),
|
||||
t('brand.voice.do.1', locale)
|
||||
]
|
||||
|
||||
const dontExamples = [
|
||||
t('brand.voice.dont.0', locale),
|
||||
t('brand.voice.dont.1', locale)
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-10 lg:px-20 lg:py-12">
|
||||
<SectionHeader align="start" max-width="xl">
|
||||
{{ t('brand.voice.heading', locale) }}
|
||||
</SectionHeader>
|
||||
|
||||
<dl class="mt-10 flex max-w-4xl flex-col gap-3.5 text-sm leading-[1.6]">
|
||||
<div v-for="principle in principles" :key="principle.title">
|
||||
<dt class="text-primary-comfy-yellow">{{ principle.title }}</dt>
|
||||
<dd class="text-primary-warm-gray">{{ principle.body }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="mt-12 grid gap-4 md:grid-cols-2">
|
||||
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow size-2.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-bold tracking-wider text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ t('brand.voice.doLabel', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="example in doExamples"
|
||||
:key="example"
|
||||
class="bg-transparency-ink-t80 rounded-2xl p-5"
|
||||
>
|
||||
<p class="text-base/[1.45] text-primary-comfy-canvas">
|
||||
{{ example }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-transparency-white-t4 flex flex-col gap-4 rounded-4xl p-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="bg-primary-warm-gray size-2.5 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
class="text-primary-warm-gray text-sm font-bold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('brand.voice.dontLabel', locale) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="example in dontExamples"
|
||||
:key="example"
|
||||
class="bg-transparency-ink-t80 rounded-2xl p-5"
|
||||
>
|
||||
<p class="text-primary-warm-gray text-base/[1.45] line-through">
|
||||
{{ example }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -7,35 +7,40 @@ import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const PROMPT = t('mcp.hero.demoPrompt', locale)
|
||||
const generateLabel = t('mcp.hero.demoGenerate', locale)
|
||||
|
||||
// Each cycle types the prompt that produces the card about to slide in.
|
||||
const cards = [
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptMoodboard',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'moodboard_v1.png · 6-up',
|
||||
tag: 'Gmail',
|
||||
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptConcepts',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'concepts_01–03.png',
|
||||
tag: 'Notion',
|
||||
thumb: '/images/mcp/mcp-thumb-concepts.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptKeyart',
|
||||
actionKey: 'mcp.hero.demoActionGenerateImage',
|
||||
file: 'hero_keyart.png',
|
||||
tag: 'Figma',
|
||||
thumb: '/images/mcp/mcp-thumb-keyart.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptPbr',
|
||||
actionKey: 'mcp.hero.demoActionGenerate3d',
|
||||
file: 'asphalt_pbr/ · 5 maps',
|
||||
tag: 'Blender',
|
||||
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
|
||||
},
|
||||
{
|
||||
promptKey: 'mcp.hero.demoPromptUpscale',
|
||||
actionKey: 'mcp.hero.demoActionUpscale',
|
||||
file: 'kaiju_neon_4k.png · 4096',
|
||||
tag: null,
|
||||
@@ -65,15 +70,15 @@ function schedule(fn: () => void, ms: number) {
|
||||
}, ms)
|
||||
}
|
||||
|
||||
function typePrompt(onDone: () => void) {
|
||||
function typePrompt(prompt: string, onDone: () => void) {
|
||||
displayedPrompt.value = ''
|
||||
promptDone.value = false
|
||||
let i = 0
|
||||
|
||||
function step() {
|
||||
i++
|
||||
displayedPrompt.value = PROMPT.slice(0, i)
|
||||
if (i < PROMPT.length) {
|
||||
displayedPrompt.value = prompt.slice(0, i)
|
||||
if (i < prompt.length) {
|
||||
schedule(step, 35)
|
||||
} else {
|
||||
promptDone.value = true
|
||||
@@ -94,8 +99,8 @@ function revealNextCard() {
|
||||
return
|
||||
}
|
||||
|
||||
// Type the prompt, then slide in the next card
|
||||
typePrompt(() => {
|
||||
// Type the next card's prompt, then slide that card in
|
||||
typePrompt(t(cards[visibleCount.value].promptKey, locale), () => {
|
||||
visibleCount.value++
|
||||
schedule(revealNextCard, 400)
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
|
||||
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const
|
||||
|
||||
const faqs = faqNumbers.map((n) => ({
|
||||
id: String(n),
|
||||
|
||||
@@ -11,9 +11,10 @@ const ctas = mcpCtas(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 5rem/6.75rem = HeaderMain's rendered height (py-5 / lg:py-8) so the hero fills the viewport below the sticky nav -->
|
||||
<HeroSplit01
|
||||
:locale="locale"
|
||||
class="min-h-screen"
|
||||
class="min-h-[calc(100svh-5rem)] lg:min-h-[calc(100svh-6.75rem)]"
|
||||
badge-text="MCP"
|
||||
:title="t('mcp.hero.heading', locale)"
|
||||
:subtitle="t('mcp.hero.subtitle', locale)"
|
||||
|
||||
@@ -23,7 +23,7 @@ const steps: FeatureStep[] = stepNumbers.map((n) => ({
|
||||
<FeatureGrid02
|
||||
:heading="t('mcp.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
:primary-cta="ctas.runWorkflow"
|
||||
:primary-cta="ctas.installMcp"
|
||||
:secondary-cta="ctas.docs"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,69 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpRight } from '@lucide/vue'
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
|
||||
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
|
||||
import SectionHeader from '../../components/common/SectionHeader.vue'
|
||||
import SectionLabel from '../../components/common/SectionLabel.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'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const cards: FeatureCard[] = [
|
||||
const agentCommand = t('mcp.setup.option1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
|
||||
interface McpClient {
|
||||
id: string
|
||||
name: string
|
||||
step: string
|
||||
command?: string
|
||||
link?: { label: string; href: string }
|
||||
}
|
||||
|
||||
const clients: McpClient[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
label: t('mcp.setup.step1.label', locale),
|
||||
title: t('mcp.setup.step1.title', locale),
|
||||
description: t('mcp.setup.step1.description', locale),
|
||||
action: {
|
||||
type: 'code',
|
||||
value: t('mcp.setup.step1.command', locale).replace(
|
||||
'{url}',
|
||||
externalLinks.docsMcp
|
||||
)
|
||||
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)
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
step: t('mcp.setup.clients.cursor.step', locale),
|
||||
link: {
|
||||
label: t('mcp.setup.clients.cursor.linkLabel', locale),
|
||||
href: externalLinks.apiKeys
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
label: t('mcp.setup.step2.label', locale),
|
||||
title: t('mcp.setup.step2.title', locale),
|
||||
description: t('mcp.setup.step2.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step2.cta', locale),
|
||||
href: externalLinks.docsMcp,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
}
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
step: t('mcp.setup.clients.codex.step', locale),
|
||||
command: `codex mcp add comfy-cloud --url ${externalLinks.mcpEndpoint}`
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
label: t('mcp.setup.step3.label', locale),
|
||||
title: t('mcp.setup.step3.title', locale),
|
||||
description: t('mcp.setup.step3.description', locale),
|
||||
action: {
|
||||
type: 'link',
|
||||
label: t('mcp.setup.step3.cta', locale),
|
||||
href: externalLinks.mcpSkills,
|
||||
target: '_blank',
|
||||
icon: ArrowUpRight,
|
||||
variant: 'default'
|
||||
id: 'other',
|
||||
name: t('mcp.setup.clients.other.name', locale),
|
||||
step: t('mcp.setup.clients.other.step', locale),
|
||||
link: {
|
||||
label: t('mcp.setup.clients.other.linkLabel', locale),
|
||||
href: externalLinks.docsMcp
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const copyLabel = t('ui.copy', locale)
|
||||
const copiedLabel = t('ui.copied', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureGrid01
|
||||
<section
|
||||
id="setup"
|
||||
class="scroll-mt-24 lg:scroll-mt-36"
|
||||
:eyebrow="t('mcp.setup.label', locale)"
|
||||
:heading="t('mcp.setup.heading', locale)"
|
||||
:subtitle="t('mcp.setup.subtitle', locale)"
|
||||
:columns="3"
|
||||
:cards="cards"
|
||||
:copy-label="t('ui.copy', locale)"
|
||||
:copied-label="t('ui.copied', locale)"
|
||||
/>
|
||||
class="max-w-9xl mx-auto scroll-mt-24 px-6 py-16 lg:scroll-mt-36 lg:py-24"
|
||||
>
|
||||
<SectionHeader
|
||||
max-width="xl"
|
||||
:label="t('mcp.setup.label', locale)"
|
||||
align="start"
|
||||
>
|
||||
{{ t('mcp.setup.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
|
||||
{{ t('mcp.setup.subtitle', locale) }}
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<SectionLabel>{{ t('mcp.setup.option1.label', locale) }}</SectionLabel>
|
||||
<h3
|
||||
class="mt-3 text-xl font-light text-primary-comfy-canvas lg:text-2xl"
|
||||
>
|
||||
{{ 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>
|
||||
|
||||
<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"
|
||||
>
|
||||
{{ 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>
|
||||
<TabsContent
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
:value="client.id"
|
||||
class="mt-4 flex min-h-24 flex-col gap-3"
|
||||
>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ client.step
|
||||
}}<a
|
||||
v-if="client.link"
|
||||
:href="client.link.href"
|
||||
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"
|
||||
>{{ client.link.label }}</a
|
||||
>
|
||||
</p>
|
||||
<CopyableField
|
||||
v-if="client.command"
|
||||
:value="client.command"
|
||||
:copy-label="copyLabel"
|
||||
:copied-label="copiedLabel"
|
||||
/>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -7,16 +7,21 @@ import { t } from '../../i18n/translations'
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
type ToolMedia =
|
||||
| { type: 'image'; src: string }
|
||||
| { type: 'image'; src: string; fit?: 'cover' | 'contain' }
|
||||
| {
|
||||
type: 'video'
|
||||
src: string
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
hideControls?: boolean
|
||||
fit?: 'cover' | 'contain'
|
||||
}
|
||||
|
||||
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
const tools: {
|
||||
n: 1 | 2 | 3 | 4 | 5 | 6
|
||||
media: ToolMedia
|
||||
altKey?: TranslationKey
|
||||
}[] = [
|
||||
{
|
||||
n: 1,
|
||||
media: {
|
||||
@@ -40,9 +45,38 @@ const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
|
||||
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true
|
||||
hideControls: true,
|
||||
fit: 'contain'
|
||||
},
|
||||
altKey: 'mcp.tools.3.alt'
|
||||
},
|
||||
{
|
||||
n: 4,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/direct-any-model.png'
|
||||
},
|
||||
altKey: 'mcp.tools.4.alt'
|
||||
},
|
||||
{
|
||||
n: 5,
|
||||
media: {
|
||||
type: 'image',
|
||||
src: 'https://media.comfy.org/website/mcp/generate-in-batches.png'
|
||||
},
|
||||
altKey: 'mcp.tools.5.alt'
|
||||
},
|
||||
{
|
||||
n: 6,
|
||||
media: {
|
||||
type: 'video',
|
||||
src: 'https://media.comfy.org/website/homepage/showcase/ui-overview.webm',
|
||||
autoplay: true,
|
||||
loop: true,
|
||||
hideControls: true,
|
||||
fit: 'contain'
|
||||
},
|
||||
altKey: 'mcp.tools.6.alt'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
@@ -9,14 +9,13 @@ export interface McpCta {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
|
||||
* steps, or run a workflow in the cloud. The hero leads with install + docs;
|
||||
* the "how it works" section pairs run-a-workflow with docs.
|
||||
* Calls-to-action for the MCP page: view the docs or jump to the on-page
|
||||
* setup options. Both the hero and the "how it works" section pair install
|
||||
* with docs.
|
||||
*/
|
||||
export function mcpCtas(locale: Locale): {
|
||||
docs: McpCta
|
||||
installMcp: McpCta
|
||||
runWorkflow: McpCta
|
||||
} {
|
||||
return {
|
||||
docs: {
|
||||
@@ -27,10 +26,6 @@ export function mcpCtas(locale: Locale): {
|
||||
installMcp: {
|
||||
label: t('mcp.hero.installMcp', locale),
|
||||
href: '#setup'
|
||||
},
|
||||
runWorkflow: {
|
||||
label: t('mcp.hero.runWorkflow', locale),
|
||||
href: getRoutes(locale).cloud
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,31 @@ describe('comfyUiSourceCodeNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ComfyUI entity links', () => {
|
||||
it('names the home page as the canonical page for the application', () => {
|
||||
const node = comfyUiApplicationNode(siteUrl)
|
||||
expect(node.mainEntityOfPage).toBe(`${siteUrl}/`)
|
||||
})
|
||||
|
||||
it('links the application back to the source code emitted alongside it', () => {
|
||||
const app = comfyUiApplicationNode(siteUrl)
|
||||
const source = comfyUiSourceCodeNode(siteUrl)
|
||||
expect(app.isBasedOn).toEqual({ '@id': source['@id'] })
|
||||
})
|
||||
|
||||
it('claims no canonical page or source code for third-party software', () => {
|
||||
const node = softwareApplicationNode({
|
||||
siteUrl,
|
||||
id: 'https://comfy.org/p/supported-models/foo/#software',
|
||||
name: 'Foo Model',
|
||||
url: 'https://comfy.org/p/supported-models/foo/',
|
||||
applicationCategory: 'MultimediaApplication'
|
||||
})
|
||||
expect(node.mainEntityOfPage).toBeUndefined()
|
||||
expect(node.isBasedOn).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageGraph', () => {
|
||||
const url = 'https://comfy.org/cloud/pricing/'
|
||||
const graph = buildPageGraph(
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface SoftwareAppInput {
|
||||
authorName?: string
|
||||
isFree?: boolean
|
||||
sameAs?: string[]
|
||||
mainEntityOfPage?: string
|
||||
isBasedOnId?: string
|
||||
}
|
||||
|
||||
export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
@@ -219,6 +221,8 @@ export function softwareApplicationNode(input: SoftwareAppInput): JsonLdNode {
|
||||
author,
|
||||
publisher: input.firstParty ? orgRef : undefined,
|
||||
sameAs: input.sameAs,
|
||||
mainEntityOfPage: input.mainEntityOfPage,
|
||||
isBasedOn: input.isBasedOnId ? { '@id': input.isBasedOnId } : undefined,
|
||||
offers: input.isFree
|
||||
? {
|
||||
'@type': 'Offer',
|
||||
@@ -257,6 +261,10 @@ export function comfyUiSoftwareId(siteUrl: string): string {
|
||||
return `${siteUrl}/#software`
|
||||
}
|
||||
|
||||
function comfyUiSourceCodeId(siteUrl: string): string {
|
||||
return `${siteUrl}/#sourcecode`
|
||||
}
|
||||
|
||||
export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
return softwareApplicationNode({
|
||||
siteUrl,
|
||||
@@ -267,14 +275,16 @@ export function comfyUiApplicationNode(siteUrl: string): JsonLdNode {
|
||||
applicationCategory: 'MultimediaApplication',
|
||||
operatingSystem: 'Windows, macOS, Linux',
|
||||
isFree: true,
|
||||
sameAs: comfyUiSameAs
|
||||
sameAs: comfyUiSameAs,
|
||||
mainEntityOfPage: `${siteUrl}/`,
|
||||
isBasedOnId: comfyUiSourceCodeId(siteUrl)
|
||||
})
|
||||
}
|
||||
|
||||
export function comfyUiSourceCodeNode(siteUrl: string): JsonLdNode {
|
||||
return softwareSourceCodeNode({
|
||||
siteUrl,
|
||||
id: `${siteUrl}/#sourcecode`,
|
||||
id: comfyUiSourceCodeId(siteUrl),
|
||||
name: 'ComfyUI',
|
||||
codeRepository: externalLinks.github,
|
||||
programmingLanguage: 'Python',
|
||||
|
||||
@@ -7,6 +7,45 @@ import {
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Vue Upload Widgets', { tag: '@vue-nodes' }, () => {
|
||||
test.describe('media selection', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
})
|
||||
|
||||
test('keeps a selected image loaded when it is selected again', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
expect(loadImageNodes, 'Workflow has one Load Image node').toHaveLength(1)
|
||||
const [loadImageNode] = loadImageNodes
|
||||
|
||||
const imageWidget = await loadImageNode.getWidgetByName('image')
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
|
||||
const imageLoadError = node.getByTestId(TestIds.errors.imageLoadError)
|
||||
const selectedImageButton = node.getByRole('button', {
|
||||
name: 'example.png',
|
||||
exact: true
|
||||
})
|
||||
await expect(selectedImageButton).toBeVisible()
|
||||
await expect(imageLoadError).toBeHidden()
|
||||
|
||||
await selectedImageButton.click()
|
||||
|
||||
const menu = comfyPage.page.getByTestId('form-dropdown-menu')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByText('example.png', { exact: true }).click()
|
||||
await expect(menu).toBeHidden()
|
||||
|
||||
await expect(selectedImageButton).toBeFocused()
|
||||
await expect(selectedImageButton).toBeVisible()
|
||||
await expect.poll(() => imageWidget.getValue()).toBe('example.png')
|
||||
await expect(imageLoadError).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
|
||||
|
||||
|
||||
219
src/components/cameraInfo/CameraInfo.vue
Normal file
219
src/components/cameraInfo/CameraInfo.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<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))
|
||||
|
||||
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]',
|
||||
'text-lg 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>
|
||||
|
||||
209
src/composables/useCameraInfo.test.ts
Normal file
209
src/composables/useCameraInfo.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
151
src/composables/useCameraInfo.ts
Normal file
151
src/composables/useCameraInfo.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
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('Failed to initialize Camera Info viewport.')
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
unwireWidgets()
|
||||
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 {
|
||||
target.onMouseEnter = useChainCallback(target.onMouseEnter, () => {
|
||||
viewport?.viewport.updateStatusMouseOnNode(true)
|
||||
viewport?.viewport.refreshViewport()
|
||||
})
|
||||
target.onMouseLeave = useChainCallback(target.onMouseLeave, () => {
|
||||
viewport?.viewport.updateStatusMouseOnNode(false)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
wrappedWidgets.length = 0
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
cleanup,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setGizmosVisible,
|
||||
setTransformGizmoMode,
|
||||
setLookThrough,
|
||||
mode
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
148
src/extensions/core/cameraInfo/CameraInfoOverlay.test.ts
Normal file
148
src/extensions/core/cameraInfo/CameraInfoOverlay.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { CameraInfoOverlay } from './CameraInfoOverlay'
|
||||
import { DEFAULT_CAMERA_INFO_STATE, 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 rebuilds the helper', () => {
|
||||
const initialHelper = ctx.scene.children.find(
|
||||
(c) => c instanceof THREE.CameraHelper
|
||||
)
|
||||
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
|
||||
expect(ctx.overlay.getSubjectCamera()).toBeInstanceOf(
|
||||
THREE.OrthographicCamera
|
||||
)
|
||||
const newHelper = ctx.scene.children.find(
|
||||
(c) => c instanceof THREE.CameraHelper
|
||||
)
|
||||
expect(newHelper).toBeDefined()
|
||||
expect(newHelper).not.toBe(initialHelper)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
213
src/extensions/core/cameraInfo/CameraInfoOverlay.ts
Normal file
213
src/extensions/core/cameraInfo/CameraInfoOverlay.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { SceneOverlay } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import {
|
||||
DEFAULT_CAMERA_INFO_STATE,
|
||||
type CameraInfoCameraType,
|
||||
type 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
305
src/extensions/core/cameraInfo/CameraInfoViewport.test.ts
Normal file
305
src/extensions/core/cameraInfo/CameraInfoViewport.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
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 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(),
|
||||
addPostRenderCallback: vi.fn((cb: () => void) => {
|
||||
postRender = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
setExternalActiveCamera: vi.fn(),
|
||||
forceRender: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
699
src/extensions/core/cameraInfo/CameraInfoViewport.ts
Normal file
699
src/extensions/core/cameraInfo/CameraInfoViewport.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { 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,
|
||||
type CameraHandleMode,
|
||||
type CameraHandleTransform
|
||||
} from './handles/CameraHandle'
|
||||
import { OrbitHandles, 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,
|
||||
type CameraInfoFieldName,
|
||||
type CameraInfoMode,
|
||||
type 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 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.disposePostRender = this.viewport.addPostRenderCallback(() =>
|
||||
this.onPostRender()
|
||||
)
|
||||
}
|
||||
|
||||
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.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.transformGizmoMode === 'target' && targetApplies(mode)
|
||||
this.targetHandle.setVisible(wantTarget)
|
||||
|
||||
const wantTranslate =
|
||||
this.transformGizmoMode === 'camera-translate' &&
|
||||
cameraTranslateApplies(mode)
|
||||
const wantRotate =
|
||||
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 onPostRender(): void {
|
||||
if (this.lookingThrough) {
|
||||
if (this.fitSubjectAspect()) this.viewport.forceRender()
|
||||
return
|
||||
}
|
||||
this.renderSubjectCameraPreview()
|
||||
}
|
||||
|
||||
private fitSubjectAspect(): boolean {
|
||||
const canvas = this.viewport.domElement
|
||||
const aspect = canvas.width / canvas.height
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) return false
|
||||
const cam = this.overlay.getSubjectCamera()
|
||||
if (cam instanceof THREE.PerspectiveCamera) {
|
||||
if (Math.abs(cam.aspect - aspect) < 1e-4) return false
|
||||
cam.aspect = aspect
|
||||
cam.updateProjectionMatrix()
|
||||
return true
|
||||
}
|
||||
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 false
|
||||
cam.left = left
|
||||
cam.right = right
|
||||
cam.updateProjectionMatrix()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
145
src/extensions/core/cameraInfo/cameraTransform.test.ts
Normal file
145
src/extensions/core/cameraInfo/cameraTransform.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import { DEFAULT_CAMERA_INFO_STATE, 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())
|
||||
expect(a.quaternion.equals(b.quaternion)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
103
src/extensions/core/cameraInfo/handles/CameraHandle.test.ts
Normal file
103
src/extensions/core/cameraInfo/handles/CameraHandle.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
CameraHandle,
|
||||
type CameraHandleMode,
|
||||
type CameraHandleTransform
|
||||
} from './CameraHandle'
|
||||
|
||||
describe('CameraHandle', () => {
|
||||
let scene: THREE.Scene
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let dom: HTMLElement
|
||||
let onDragging: (dragging: boolean) => void
|
||||
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')
|
||||
onDragging = vi.fn() 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)
|
||||
})
|
||||
|
||||
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('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)
|
||||
}
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
102
src/extensions/core/cameraInfo/handles/handlePicking.test.ts
Normal file
102
src/extensions/core/cameraInfo/handles/handlePicking.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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', () => {
|
||||
const near = makeHandle('yaw', new THREE.Vector3(0.05, 0, 5))
|
||||
const far = makeHandle('pitch', new THREE.Vector3(-0.3, 0, 5))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0.02, 0),
|
||||
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)
|
||||
}
|
||||
64
src/extensions/core/cameraInfo/handles/rollDragMath.test.ts
Normal file
64
src/extensions/core/cameraInfo/handles/rollDragMath.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
40
src/extensions/core/cameraInfo/handles/rollDragMath.ts
Normal file
40
src/extensions/core/cameraInfo/handles/rollDragMath.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
).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
|
||||
202
src/extensions/core/cameraInfo/lookThroughDragMath.test.ts
Normal file
202
src/extensions/core/cameraInfo/lookThroughDragMath.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
|
||||
import { DEFAULT_CAMERA_INFO_STATE, 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('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 = Math.max(MIN_DISTANCE, distance * factor)
|
||||
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 }
|
||||
}
|
||||
}
|
||||
113
src/extensions/core/cameraInfo/widgetBridge.test.ts
Normal file
113
src/extensions/core/cameraInfo/widgetBridge.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import {
|
||||
readStateFromWidgets,
|
||||
writeWidgetValue,
|
||||
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,
|
||||
type CameraInfoCameraType,
|
||||
type CameraInfoMode,
|
||||
type 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -171,10 +174,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 +197,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,8 @@ function makeInstance() {
|
||||
eventManager,
|
||||
adapterRef: { current: null },
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
handleResize: vi.fn(),
|
||||
postRenderCallbacks: []
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -249,7 +249,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 +260,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', () => {
|
||||
|
||||
@@ -184,6 +184,7 @@ export class Viewport3d {
|
||||
private renderView(): void {
|
||||
this.view.beginRender()
|
||||
this.renderMainScene()
|
||||
this.runPostRenderCallbacks()
|
||||
|
||||
this.renderer.setScissorTest(false)
|
||||
this.viewHelperManager.render(
|
||||
@@ -194,6 +195,20 @@ export class Viewport3d {
|
||||
this.view.blit()
|
||||
}
|
||||
|
||||
private readonly postRenderCallbacks: Array<() => void> = []
|
||||
|
||||
addPostRenderCallback(cb: () => void): () => void {
|
||||
this.postRenderCallbacks.push(cb)
|
||||
return () => {
|
||||
const i = this.postRenderCallbacks.indexOf(cb)
|
||||
if (i >= 0) this.postRenderCallbacks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
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 +368,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",
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const analytics = {
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
identify: vi.fn().mockResolvedValue(undefined),
|
||||
track: vi.fn().mockResolvedValue(undefined),
|
||||
reset: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
let resolvedCb: ((user: { id: string }) => void) | undefined
|
||||
let logoutCb: (() => void) | undefined
|
||||
const resolvedUserInfo = { value: null as { id: string } | null }
|
||||
return {
|
||||
analytics,
|
||||
load: vi.fn(() => analytics),
|
||||
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
|
||||
userEmail: { value: null as string | null },
|
||||
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
|
||||
resolvedCb = cb
|
||||
if (resolvedUserInfo.value) cb(resolvedUserInfo.value)
|
||||
}),
|
||||
onUserLogout: vi.fn((cb: () => void) => {
|
||||
logoutCb = cb
|
||||
}),
|
||||
resolveUser: (id: string) => resolvedCb?.({ id }),
|
||||
logoutUser: () => logoutCb?.()
|
||||
resolvedUserInfo,
|
||||
resolveUser: (id: string) => {
|
||||
resolvedUserInfo.value = { id }
|
||||
resolvedCb?.({ id })
|
||||
},
|
||||
logoutUser: () => {
|
||||
resolvedUserInfo.value = null
|
||||
logoutCb?.()
|
||||
},
|
||||
resetCallbacks: () => {
|
||||
resolvedCb = undefined
|
||||
logoutCb = undefined
|
||||
resolvedUserInfo.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,6 +46,8 @@ vi.mock('@customerio/cdp-analytics-browser', () => ({
|
||||
|
||||
vi.mock('@/composables/auth/useCurrentUser', () => ({
|
||||
useCurrentUser: () => ({
|
||||
userEmail: hoisted.userEmail,
|
||||
resolvedUserInfo: hoisted.resolvedUserInfo,
|
||||
onUserResolved: hoisted.onUserResolved,
|
||||
onUserLogout: hoisted.onUserLogout
|
||||
})
|
||||
@@ -54,14 +71,32 @@ function createProvider(
|
||||
return new CustomerIoTelemetryProvider()
|
||||
}
|
||||
|
||||
function createDeferred() {
|
||||
let resolve = () => {}
|
||||
const promise = new Promise<void>((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('CustomerIoTelemetryProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hoisted.resetCallbacks()
|
||||
hoisted.load.mockReturnValue(hoisted.analytics)
|
||||
hoisted.analytics.identify.mockResolvedValue(undefined)
|
||||
hoisted.analytics.track.mockResolvedValue(undefined)
|
||||
hoisted.analytics.reset.mockReset().mockResolvedValue(undefined)
|
||||
hoisted.analytics.register.mockResolvedValue(undefined)
|
||||
hoisted.userEmail.value = null
|
||||
window.__CONFIG__ = {} as typeof window.__CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('loads the client and registers the in-app plugin with the site id', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
@@ -89,13 +124,19 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(hoisted.load).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identifies the person by uid only on auth resolve', async () => {
|
||||
it('identifies the resolved user with uid and email traits', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'user@example.com'
|
||||
hoisted.resolveUser('test-uid-7f3a9c')
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'test-uid-7f3a9c',
|
||||
{ email: 'user@example.com' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('identifies with the configured user_id override without waiting for auth', async () => {
|
||||
@@ -108,8 +149,54 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
|
||||
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'forced-uid',
|
||||
undefined
|
||||
)
|
||||
expect(hoisted.onUserResolved).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies a restored session with the configured user id once', async () => {
|
||||
hoisted.userEmail.value = 'restored@example.com'
|
||||
hoisted.resolvedUserInfo.value = { id: 'resolved-uid' }
|
||||
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid', {
|
||||
email: 'restored@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('re-identifies with the configured user id after logout and re-login', async () => {
|
||||
createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
hoisted.userEmail.value = 'returning@example.com'
|
||||
hoisted.resolveUser('resolved-uid')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'forced-uid',
|
||||
{ email: 'returning@example.com' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('identifies before flushing events buffered before the SDK loads', async () => {
|
||||
@@ -129,13 +216,99 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
expect(identifyOrder).toBeLessThan(trackOrder)
|
||||
})
|
||||
|
||||
it('restores the resolved user after flushing an older auth event', async () => {
|
||||
let activeUser: string | null = null
|
||||
const trackedUsers: Array<[string, string | null]> = []
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation((event: string) => {
|
||||
trackedUsers.push([event, activeUser])
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.userEmail.value = 'current@example.com'
|
||||
hoisted.resolvedUserInfo.value = { id: 'current-uid' }
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'queued-uid',
|
||||
email: 'queued@example.com'
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify.mock.calls).toEqual([
|
||||
['current-uid', { email: 'current@example.com' }],
|
||||
['queued-uid', { email: 'queued@example.com' }],
|
||||
['current-uid', { email: 'current@example.com' }]
|
||||
])
|
||||
)
|
||||
expect(activeUser).toBe('current-uid')
|
||||
expect(trackedUsers).toEqual([
|
||||
['app:user_auth_completed', 'queued-uid'],
|
||||
['execution_start', 'current-uid']
|
||||
])
|
||||
})
|
||||
|
||||
it('resets identity after flushing auth for a signed-out user', async () => {
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation(() => {
|
||||
trackedUser = activeUser
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementation(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'queued-uid',
|
||||
email: 'queued@example.com'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
|
||||
expect(trackedUser).toBe('queued-uid')
|
||||
expect(activeUser).toBeNull()
|
||||
})
|
||||
|
||||
it('resets on logout', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
)
|
||||
})
|
||||
|
||||
it('continues tracking after reset fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.reset.mockRejectedValueOnce(new Error('reset failed'))
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.logoutUser()
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'execution_start',
|
||||
SOURCE
|
||||
)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to process Customer.io operation:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
const DIRECT_EVENTS: Array<{
|
||||
@@ -189,6 +362,7 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
invoke: (p) =>
|
||||
p.trackShareFlow({
|
||||
step: 'dialog_opened',
|
||||
share_id: 'share-1',
|
||||
view_mode: 'graph',
|
||||
is_app_mode: false
|
||||
}),
|
||||
@@ -209,10 +383,280 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
|
||||
invoke(provider)
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('awaits auth identification before tracking without raw identifiers', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
|
||||
provider.trackAuth({
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com',
|
||||
share_id: 'share-1'
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith('uid-1', {
|
||||
email: 'person@example.com'
|
||||
})
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{
|
||||
...SOURCE,
|
||||
method: 'google',
|
||||
is_new_user: true,
|
||||
user_id: 'uid-1'
|
||||
}
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.track.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('reuses matching resolved-user identification for auth delivery', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks auth before resetting identity on logout', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
const trackResult = createDeferred()
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementationOnce((userId: string) => {
|
||||
activeUser = userId
|
||||
return identifyResult.promise
|
||||
})
|
||||
hoisted.analytics.track.mockImplementationOnce(() => {
|
||||
trackedUser = activeUser
|
||||
return trackResult.promise
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementationOnce(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
hoisted.logoutUser()
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() => expect(hoisted.analytics.reset).toHaveBeenCalled())
|
||||
expect(trackedUser).toBe('uid-1')
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.reset.mock.invocationCallOrder[0]
|
||||
)
|
||||
trackResult.resolve()
|
||||
})
|
||||
|
||||
it('restores signed-out identity when auth delivery follows logout', async () => {
|
||||
let activeUser: string | null = null
|
||||
let trackedUser: string | null = null
|
||||
hoisted.analytics.identify.mockImplementation((userId: string) => {
|
||||
activeUser = userId
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.track.mockImplementation(() => {
|
||||
trackedUser = activeUser
|
||||
return Promise.resolve()
|
||||
})
|
||||
hoisted.analytics.reset.mockImplementation(() => {
|
||||
activeUser = null
|
||||
})
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
hoisted.logoutUser()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.reset).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(trackedUser).toBe('uid-1')
|
||||
expect(activeUser).toBeNull()
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.reset.mock.invocationCallOrder[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('restores a configured identity after tracking auth with the Firebase uid', async () => {
|
||||
const provider = createProvider({
|
||||
customer_io: {
|
||||
write_key: WRITE_KEY,
|
||||
site_id: SITE_ID,
|
||||
user_id: 'forced-uid'
|
||||
}
|
||||
})
|
||||
await vi.dynamicImportSettled()
|
||||
hoisted.analytics.identify.mockClear()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'firebase-uid',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify.mock.calls).toEqual([
|
||||
['firebase-uid', { email: 'person@example.com' }],
|
||||
['forced-uid', undefined]
|
||||
])
|
||||
)
|
||||
expect(hoisted.analytics.track.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
hoisted.analytics.identify.mock.invocationCallOrder[1]
|
||||
)
|
||||
})
|
||||
|
||||
it('does not reset identity when login resolution follows auth tracking', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
|
||||
hoisted.userEmail.value = 'person@example.com'
|
||||
hoisted.resolveUser('uid-1')
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledOnce()
|
||||
expect(hoisted.analytics.reset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stall later events when identification never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.identify.mockReturnValueOnce(new Promise(() => {}))
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-1', email: 'person@example.com' })
|
||||
provider.trackWorkflowExecution()
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
|
||||
['execution_start', SOURCE]
|
||||
])
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to identify Customer.io user:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks auth after identifying a user without an email', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'uid-without-email' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenCalledWith(
|
||||
'uid-without-email',
|
||||
undefined
|
||||
)
|
||||
)
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-without-email' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('tracks auth without identifying when user_id is absent', async () => {
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ method: 'google', email: 'person@example.com' })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, method: 'google' }
|
||||
)
|
||||
)
|
||||
expect(hoisted.analytics.identify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks auth after identification fails', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.analytics.identify.mockRejectedValueOnce(
|
||||
new Error('identify failed')
|
||||
)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledWith(
|
||||
'app:user_auth_completed',
|
||||
{ ...SOURCE, user_id: 'uid-1' }
|
||||
)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to identify Customer.io user:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('flushes events buffered before load once, in order', async () => {
|
||||
const provider = createProvider()
|
||||
provider.trackWorkflowExecution()
|
||||
@@ -227,6 +671,67 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for queued auth identification before later events', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
provider.trackAuth({
|
||||
user_id: 'uid-1',
|
||||
email: 'person@example.com'
|
||||
})
|
||||
provider.trackWorkflowExecution()
|
||||
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(hoisted.analytics.track).not.toHaveBeenCalled()
|
||||
identifyResult.resolve()
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['app:user_auth_completed', { ...SOURCE, user_id: 'uid-1' }],
|
||||
['execution_start', SOURCE]
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('does not wait for event delivery before handing off later events', async () => {
|
||||
const trackResult = createDeferred()
|
||||
hoisted.analytics.track.mockReturnValueOnce(trackResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.track.mock.calls).toEqual([
|
||||
['execution_start', SOURCE],
|
||||
['app:add_api_credit_button_clicked', SOURCE]
|
||||
])
|
||||
)
|
||||
trackResult.resolve()
|
||||
})
|
||||
|
||||
it('snapshots resolved user email before queued identification', async () => {
|
||||
const identifyResult = createDeferred()
|
||||
hoisted.analytics.identify.mockReturnValueOnce(identifyResult.promise)
|
||||
const provider = createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
provider.trackAuth({ user_id: 'blocking-uid' })
|
||||
hoisted.userEmail.value = 'first@example.com'
|
||||
hoisted.resolveUser('resolved-uid')
|
||||
hoisted.userEmail.value = 'second@example.com'
|
||||
identifyResult.resolve()
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(hoisted.analytics.identify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'resolved-uid',
|
||||
{ email: 'first@example.com' }
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('disables tracking when the SDK fails to load', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
hoisted.load.mockImplementation(() => {
|
||||
@@ -253,12 +758,12 @@ describe('CustomerIoTelemetryProvider', () => {
|
||||
provider.trackWorkflowExecution()
|
||||
provider.trackAddApiCreditButtonClicked()
|
||||
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() =>
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'Failed to track Customer.io event:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
|
||||
import { omit, withTimeout } from 'es-toolkit'
|
||||
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import type { AuthUserInfo } from '@/types/authTypes'
|
||||
|
||||
import { TelemetryEvents } from '../../types'
|
||||
import type {
|
||||
@@ -16,9 +18,17 @@ import type {
|
||||
|
||||
export const EVENT_SOURCE = 'web-sdk'
|
||||
|
||||
const SDK_OPERATION_TIMEOUT_MS = 10_000
|
||||
|
||||
interface QueuedEvent {
|
||||
event: string
|
||||
properties: Record<string, unknown>
|
||||
identity?: CustomerIoIdentity
|
||||
}
|
||||
|
||||
interface CustomerIoIdentity {
|
||||
userId: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +42,9 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
private analytics: AnalyticsBrowser | null = null
|
||||
private isEnabled = true
|
||||
private eventQueue: QueuedEvent[] = []
|
||||
private identifiedUser: CustomerIoIdentity | null = null
|
||||
private sessionIdentity: CustomerIoIdentity | null = null
|
||||
private operationQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
constructor() {
|
||||
const {
|
||||
@@ -39,6 +52,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
site_id: siteId,
|
||||
user_id: userIdOverride
|
||||
} = window.__CONFIG__?.customer_io ?? {}
|
||||
this.sessionIdentity = userIdOverride ? { userId: userIdOverride } : null
|
||||
if (!writeKey || !siteId) {
|
||||
this.isEnabled = false
|
||||
return
|
||||
@@ -60,14 +74,29 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
this.analytics = analytics
|
||||
|
||||
const currentUser = useCurrentUser()
|
||||
if (userIdOverride) {
|
||||
void analytics.identify(userIdOverride)
|
||||
} else {
|
||||
currentUser.onUserResolved((user) => void analytics.identify(user.id))
|
||||
const identifyResolvedUser = (user: AuthUserInfo) => {
|
||||
const identity = {
|
||||
userId: userIdOverride || user.id,
|
||||
email: currentUser.userEmail.value || undefined
|
||||
}
|
||||
this.sessionIdentity = identity
|
||||
return this.enqueueOperation(() => this.identify(identity))
|
||||
}
|
||||
currentUser.onUserLogout(() => void analytics.reset())
|
||||
|
||||
this.flushQueue()
|
||||
if (userIdOverride && !currentUser.resolvedUserInfo.value) {
|
||||
void this.enqueueOperation(() =>
|
||||
this.identify({ userId: userIdOverride })
|
||||
)
|
||||
}
|
||||
currentUser.onUserResolved((user) => {
|
||||
void identifyResolvedUser(user)
|
||||
})
|
||||
currentUser.onUserLogout(() => {
|
||||
this.sessionIdentity = null
|
||||
void this.enqueueOperation(() => this.resetIdentity())
|
||||
})
|
||||
|
||||
void this.flushQueue()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to load Customer.io:', error)
|
||||
@@ -76,32 +105,111 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
})
|
||||
}
|
||||
|
||||
private send(event: string, properties: Record<string, unknown>): void {
|
||||
void this.analytics?.track(event, properties)?.catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
private enqueueOperation(
|
||||
operation: () => Promise<void> | void
|
||||
): Promise<void> {
|
||||
this.operationQueue = this.operationQueue.then(operation).catch((error) => {
|
||||
console.error('Failed to process Customer.io operation:', error)
|
||||
})
|
||||
return this.operationQueue
|
||||
}
|
||||
|
||||
private track(event: string, metadata?: TelemetryEventProperties): void {
|
||||
private async resetIdentity(): Promise<void> {
|
||||
this.identifiedUser = null
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
await withTimeout(async () => {
|
||||
await analytics.reset()
|
||||
}, SDK_OPERATION_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
private async restoreSessionIdentity(): Promise<void> {
|
||||
if (this.sessionIdentity) {
|
||||
await this.identify(this.sessionIdentity)
|
||||
} else {
|
||||
await this.resetIdentity()
|
||||
}
|
||||
}
|
||||
|
||||
private async identify(identity: CustomerIoIdentity): Promise<void> {
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
|
||||
if (
|
||||
this.identifiedUser?.userId === identity.userId &&
|
||||
this.identifiedUser.email === identity.email
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.identifiedUser = identity
|
||||
try {
|
||||
await withTimeout(async () => {
|
||||
await analytics.identify(
|
||||
identity.userId,
|
||||
identity.email ? { email: identity.email } : undefined
|
||||
)
|
||||
}, SDK_OPERATION_TIMEOUT_MS)
|
||||
} catch (error) {
|
||||
this.identifiedUser = null
|
||||
console.error('Failed to identify Customer.io user:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private async send(
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
identity?: CustomerIoIdentity
|
||||
): Promise<void> {
|
||||
const analytics = this.analytics
|
||||
if (!analytics) return
|
||||
|
||||
if (identity) await this.identify(identity)
|
||||
|
||||
void analytics.track(event, properties).catch((error) => {
|
||||
console.error('Failed to track Customer.io event:', error)
|
||||
})
|
||||
|
||||
if (identity) await this.restoreSessionIdentity()
|
||||
}
|
||||
|
||||
private track(
|
||||
event: string,
|
||||
metadata?: TelemetryEventProperties,
|
||||
identity?: CustomerIoIdentity
|
||||
): void {
|
||||
if (!this.isEnabled) return
|
||||
const properties = { ...metadata, event_source: EVENT_SOURCE }
|
||||
if (this.analytics) {
|
||||
this.send(event, properties)
|
||||
void this.enqueueOperation(() => this.send(event, properties, identity))
|
||||
} else {
|
||||
this.eventQueue.push({ event, properties })
|
||||
this.eventQueue.push({ event, properties, identity })
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue(): void {
|
||||
private async flushQueue(): Promise<void> {
|
||||
if (!this.analytics) return
|
||||
for (const { event, properties } of this.eventQueue) {
|
||||
this.send(event, properties)
|
||||
}
|
||||
const queue = this.eventQueue
|
||||
this.eventQueue = []
|
||||
await this.enqueueOperation(async () => {
|
||||
for (const { event, properties, identity } of queue) {
|
||||
await this.send(event, properties, identity)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
trackAuth(metadata: AuthMetadata): void {
|
||||
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
|
||||
const identity = metadata.user_id
|
||||
? {
|
||||
userId: metadata.user_id,
|
||||
email: metadata.email || undefined
|
||||
}
|
||||
: undefined
|
||||
this.track(
|
||||
TelemetryEvents.USER_AUTH_COMPLETED,
|
||||
omit(metadata, ['email', 'share_id']),
|
||||
identity
|
||||
)
|
||||
}
|
||||
|
||||
trackSubscription(
|
||||
@@ -137,6 +245,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
|
||||
}
|
||||
|
||||
trackShareFlow(metadata: ShareFlowMetadata): void {
|
||||
this.track(TelemetryEvents.SHARE_FLOW, metadata)
|
||||
this.track(TelemetryEvents.SHARE_FLOW, omit(metadata, ['share_id']))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,14 @@ const MockFormDropdownMenu = {
|
||||
'candidateLabel'
|
||||
],
|
||||
template: `<div class="mock-menu" data-testid="dropdown-menu" :data-candidate-index="candidateIndex" :data-candidate-label="candidateLabel ?? ''" :data-items="JSON.stringify(items)">
|
||||
<button
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
@click="$emit('item-click', item, index)"
|
||||
>
|
||||
{{ item.label ?? item.name }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('search-enter')">Search enter</button>
|
||||
</div>`
|
||||
}
|
||||
@@ -79,6 +87,7 @@ interface MountDropdownOptions {
|
||||
onCleanup: (cleanupFn: () => void) => void
|
||||
) => Promise<FormDropdownItem[]>
|
||||
multiple?: boolean | number
|
||||
selected?: Set<string>
|
||||
searchQuery?: string
|
||||
onUpdateSelected?: (selected: Set<string>) => void
|
||||
onUpdateIsOpen?: (isOpen: boolean) => void
|
||||
@@ -97,6 +106,7 @@ function mountDropdown(
|
||||
props: {
|
||||
items,
|
||||
multiple: options.multiple,
|
||||
selected: options.selected,
|
||||
searcher: options.searcher,
|
||||
searchQuery: options.searchQuery,
|
||||
'onUpdate:selected': options.onUpdateSelected,
|
||||
@@ -256,6 +266,40 @@ describe('FormDropdown', () => {
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the selected item when it is clicked again in single-select mode', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const onUpdateIsOpen = vi.fn()
|
||||
const item = createItem('image', 'photo.png')
|
||||
const { user } = mountDropdown([item], {
|
||||
selected: new Set([item.id]),
|
||||
onUpdateSelected,
|
||||
onUpdateIsOpen
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: item.label }))
|
||||
|
||||
expect(onUpdateSelected).not.toHaveBeenCalled()
|
||||
expect(onUpdateIsOpen).toHaveBeenLastCalledWith(false)
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('replaces the selected item when a different item is clicked in single-select mode', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const firstItem = createItem('first', 'first.png')
|
||||
const secondItem = createItem('second', 'second.png')
|
||||
const { user } = mountDropdown([firstItem, secondItem], {
|
||||
selected: new Set([firstItem.id]),
|
||||
onUpdateSelected
|
||||
})
|
||||
await openDropdown(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: secondItem.label }))
|
||||
|
||||
expect(onUpdateSelected).toHaveBeenCalledWith(new Set([secondItem.id]))
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not select when Enter is pressed with an empty search query', async () => {
|
||||
const onUpdateSelected = vi.fn()
|
||||
const { user } = mountDropdown(
|
||||
|
||||
@@ -265,8 +265,14 @@ function handleFileChange(event: Event) {
|
||||
|
||||
function handleSelection(item: FormDropdownItem, index: number) {
|
||||
if (disabled) return
|
||||
const itemIsSelected = internalIsSelected(item, index)
|
||||
if (isSingleSelect.value && itemIsSelected) {
|
||||
closeDropdown({ restoreFocus: true })
|
||||
return
|
||||
}
|
||||
|
||||
const sel = selected.value
|
||||
if (internalIsSelected(item, index)) {
|
||||
if (itemIsSelected) {
|
||||
sel.delete(item.id)
|
||||
} else {
|
||||
if (sel.size < maxSelectable.value) {
|
||||
@@ -281,7 +287,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
}
|
||||
selected.value = new Set(sel)
|
||||
|
||||
if (maxSelectable.value === 1) {
|
||||
if (isSingleSelect.value) {
|
||||
closeDropdown({ restoreFocus: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ const Load3D = defineAsyncComponent(
|
||||
const Load3DAdvanced = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3DAdvanced.vue')
|
||||
)
|
||||
const CameraInfo = defineAsyncComponent(
|
||||
() => import('@/components/cameraInfo/CameraInfo.vue')
|
||||
)
|
||||
const WidgetImageCrop = defineAsyncComponent(
|
||||
() => import('@/components/imagecrop/WidgetImageCrop.vue')
|
||||
)
|
||||
@@ -197,6 +200,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'cameraInfo',
|
||||
{
|
||||
component: CameraInfo,
|
||||
aliases: ['CAMERA_INFO_STATE'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'imagecrop',
|
||||
{
|
||||
@@ -289,6 +300,7 @@ const EXPANDING_TYPES = [
|
||||
'textPreview',
|
||||
'load3D',
|
||||
'load3DAdvanced',
|
||||
'cameraInfo',
|
||||
'curve',
|
||||
'painter',
|
||||
'imagecompare',
|
||||
|
||||
Reference in New Issue
Block a user