Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
e893bf0eba feat(camera-info): 3D viewport widget for CreateCameraInfo node 2026-07-16 23:40:45 -04:00
56 changed files with 4584 additions and 747 deletions

View File

@@ -1,78 +0,0 @@
# Description: Deploys the internal CI dashboard (a static index page linking out to
# CI-generated reports) together with the merged Playwright HTML report to GitHub
# Pages, giving both a stable, persistent URL instead of ephemeral PR artifacts.
#
# Triggered by workflow_run (not push) so this only runs after 'CI: Tests E2E' has
# finished merging its sharded blob reports into an HTML report on main — the
# 'playwright-report-chromium' artifact this job downloads doesn't exist until that
# merge-reports job completes.
#
# NOTE: GitHub Pages must be enabled for this repo (Settings -> Pages -> Source:
# "GitHub Actions") for this job's deploy step to actually publish anything; until
# then it will fail at the deploy-pages step. See the PR description for details.
name: 'CI: Dashboard Deploy'
on:
workflow_run:
workflows: ['CI: Tests E2E']
types:
- completed
concurrency:
group: dashboard-deploy-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
(github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download merged Playwright report
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
run_id: ${{ github.event.workflow_run.id }}
name: playwright-report-chromium
path: dashboard-site/playwright-report
if_no_artifact_found: warn
- name: Check Playwright report was found
id: report
run: |
if [ -f dashboard-site/playwright-report/index.html ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No merged Playwright report artifact found for this run; skipping the dashboard deploy so the existing Pages deployment isn't wiped out." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Assemble dashboard index
if: steps.report.outputs.found == 'true'
run: cp scripts/cicd/dashboard/index.html dashboard-site/index.html
- name: Upload to GitHub Pages
if: steps.report.outputs.found == 'true'
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1
with:
path: dashboard-site
- name: Deploy to GitHub Pages
id: deployment
if: steps.report.outputs.found == 'true'
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5

View File

@@ -10,12 +10,11 @@ const PAYMENT_STATUSES = ['success', 'failed'] as const
const LOCALE_PREFIXES = LOCALES.map((locale) =>
locale === DEFAULT_LOCALE ? '' : `/${locale}`
)
const SITEMAP_EXCLUDED_PATHNAMES = new Set([
...LOCALE_PREFIXES.flatMap((prefix) =>
const SITEMAP_EXCLUDED_PATHNAMES = new Set(
LOCALE_PREFIXES.flatMap((prefix) =>
PAYMENT_STATUSES.map((status) => `${prefix}/payment/${status}`)
),
'/individual-submission'
])
)
)
function isExcludedFromSitemap(page: string): boolean {
const pathname = new URL(page).pathname.replace(/\/$/, '')

View File

@@ -1,16 +0,0 @@
<script setup lang="ts">
import Button from '../ui/button/Button.vue'
const { href, label } = defineProps<{
href: string
label: string
}>()
</script>
<template>
<div class="mt-2 flex justify-center">
<Button as="a" :href variant="default" size="lg">
{{ label }}
</Button>
</div>
</template>

View File

@@ -1,25 +1,12 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { title, class: className } = defineProps<{
title: string
class?: HTMLAttributes['class']
}>()
const { title } = defineProps<{ title: string }>()
</script>
<template>
<section
class="flex items-center justify-center px-6 pt-20 pb-16 lg:pt-32 lg:pb-24"
>
<h1
:class="
cn(
'text-4xl font-light text-primary-comfy-canvas lg:text-6xl',
className
)
"
>
<h1 class="text-primary-comfy-canvas text-4xl font-light lg:text-6xl">
{{ title }}
</h1>
</section>

View File

@@ -1,60 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import PlansPricingCta from '../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../components/legal/HeroSection.vue'
import { getRoutes } from '../config/routes'
const routes = getRoutes('en')
---
<BaseLayout
title="Thanks for reaching out - Comfy"
description="Thanks for reaching out. Based on what you shared, one of our self-serve plans is probably a better fit."
noindex
>
<HeroSection title="Thanks for reaching out." class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
Based on what you shared, one of our self-serve plans is probably a
better fit.
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>,
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>, and
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
> for individual creators, plus our new
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> plan for multiple users under shared billing.
</p>
<PlansPricingCta
href={routes.cloudPricing}
label="See plans and pricing →"
/>
<p class="text-primary-warm-gray mt-8 text-sm">
Still think you have an enterprise need?<br /> We're happy to assist. Email: <a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
Need help with something else? Email: <a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -1,57 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import PlansPricingCta from '../../components/individual-submission/PlansPricingCta.vue'
import HeroSection from '../../components/legal/HeroSection.vue'
import { getRoutes } from '../../config/routes'
const routes = getRoutes('zh-CN')
---
<BaseLayout
title="感谢您的联系 - Comfy"
description="感谢您的联系。根据您提供的信息,我们的自助服务套餐之一可能更适合您。"
noindex
>
<HeroSection title="感谢您的联系。" class="text-center" />
<section class="-mt-8 px-6 pb-24 lg:-mt-12 lg:pb-40">
<div
class="text-primary-comfy-canvas mx-auto flex max-w-2xl flex-col gap-6 text-center text-base font-light lg:text-lg"
>
<p>
根据您提供的信息,我们的自助服务套餐之一可能更适合您。面向个人创作者的
<strong class="text-primary-comfy-yellow font-semibold italic">
Standard</strong
>、
<strong class="text-primary-comfy-yellow font-semibold italic">
Creator</strong
>
<strong class="text-primary-comfy-yellow font-semibold italic">
Pro</strong
>,以及我们全新的
<strong class="text-primary-comfy-yellow font-semibold italic">
Teams</strong
> 套餐,可让多位用户共享账单。
</p>
<PlansPricingCta href={routes.cloudPricing} label="查看套餐与价格 →" />
<p class="text-primary-warm-gray mt-8 text-sm">
仍然认为您有企业级需求?我们很乐意为您提供帮助。邮箱:<a
href="mailto:gtm-team@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>gtm-team@comfy.org</a
>
</p>
<p class="text-primary-warm-gray text-sm">
需要其他方面的帮助?邮箱:<a
href="mailto:support@comfy.org"
class="text-primary-comfy-yellow whitespace-nowrap underline underline-offset-4 hover:no-underline"
>support@comfy.org</a
>
</p>
</div>
</section>
</BaseLayout>

View File

@@ -40,11 +40,6 @@ export type ComfyDesktop2TelemetryProperties = Record<
ComfyDesktop2TelemetryValue | ComfyDesktop2TelemetryValue[]
>
export type ComfyDesktop2FirebaseAuthState =
| { status: 'pending' }
| { status: 'signed_out' }
| { status: 'signed_in'; userId: string }
export interface ComfyDesktop2TerminalBridge {
subscribe(installationId?: string): Promise<TerminalRestore>
unsubscribe(installationId?: string): Promise<void>
@@ -65,7 +60,6 @@ export interface ComfyDesktop2LogsBridge {
export interface ComfyDesktop2TelemetryBridge {
capture(event: string, properties?: ComfyDesktop2TelemetryProperties): void
reportFirebaseAuthState?(state: ComfyDesktop2FirebaseAuthState): void
}
export interface ComfyDesktop2Bridge {

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-desktop-bridge-types",
"version": "0.1.3",
"version": "0.1.2",
"description": "TypeScript definitions for the Comfy Desktop hosted frontend bridge",
"homepage": "https://comfy.org",
"license": "MIT",

View File

@@ -1,126 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>ComfyUI_frontend CI Dashboard</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #1a1a1a;
--muted: #59636e;
--border: #d8dee4;
--card-bg: #f6f8fa;
--link: #0969da;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--border: #30363d;
--card-bg: #161b22;
--link: #4493f8;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 2.5rem 1.5rem;
background: var(--bg);
color: var(--fg);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial,
sans-serif;
line-height: 1.5;
}
main {
max-width: 40rem;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
p.lede {
color: var(--muted);
margin-top: 0;
margin-bottom: 2rem;
}
ul.reports {
list-style: none;
margin: 0 0 2rem;
padding: 0;
display: grid;
gap: 0.75rem;
}
ul.reports li {
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card-bg);
padding: 1rem 1.25rem;
}
ul.reports a {
color: var(--link);
font-weight: 600;
text-decoration: none;
}
ul.reports a:hover {
text-decoration: underline;
}
ul.reports .description {
color: var(--muted);
font-size: 0.9rem;
margin-top: 0.25rem;
}
footer {
color: var(--muted);
font-size: 0.85rem;
border-top: 1px solid var(--border);
padding-top: 1rem;
}
code {
background: var(--card-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
</style>
</head>
<body>
<main>
<h1>ComfyUI_frontend CI Dashboard</h1>
<p class="lede">
Persistent links to CI-generated reports and tools for the
<code>main</code> branch, rebuilt on every push.
</p>
<ul class="reports">
<li>
<a href="https://main.comfy-storybook.pages.dev">Storybook</a>
<div class="description">
Component library, built and deployed from
<code>ci-tests-storybook.yaml</code>.
</div>
</li>
<li>
<a href="./playwright-report/">Playwright report</a>
<div class="description">
Merged chromium E2E report from the latest <code>main</code> run of
<code>ci-tests-e2e.yaml</code>.
</div>
</li>
</ul>
<footer>
More report types (coverage, bundle size, lint, etc.) will be added here
in follow-up PRs as they get a persistent, hosted home. This page is
intentionally minimal for now.
</footer>
</main>
</body>
</html>

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@
export type DraggingChangeListener = (dragging: boolean) => void

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

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

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

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

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

View File

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

View File

@@ -136,7 +136,8 @@ function makeInstance() {
eventManager,
adapterRef: { current: null },
forceRender: vi.fn(),
handleResize: vi.fn()
handleResize: vi.fn(),
postRenderCallbacks: []
})
return {

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,6 @@ import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { syncHostUserIdWithFirebaseAuth } from '@/platform/telemetry/hostUserIdSync'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
@@ -142,10 +141,6 @@ app
modules: [VueFireAuth()]
})
if (isCloud && hasHostTelemetryBridge) {
syncHostUserIdWithFirebaseAuth()
}
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,

View File

@@ -1,183 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueModule from 'vue'
import { nextTick } from 'vue'
type MockAuthStore = {
isInitialized: boolean
currentUser: { uid: string } | null
}
const hoisted = vi.hoisted(() => ({
authStore: null as unknown as MockAuthStore
}))
vi.mock('@/stores/authStore', async () => {
const { reactive } = await vi.importActual<typeof VueModule>('vue')
hoisted.authStore = reactive<MockAuthStore>({
isInitialized: false,
currentUser: null
})
return { useAuthStore: () => hoisted.authStore }
})
import { syncHostUserIdWithFirebaseAuth } from './hostUserIdSync'
const stopHandles: Array<() => void> = []
function installTelemetryBridge() {
const reportFirebaseAuthState = vi.fn()
window.__comfyDesktop2 = {
isRemote: () => false,
Telemetry: {
capture: vi.fn(),
reportFirebaseAuthState
}
}
return { reportFirebaseAuthState }
}
function startSync(): void {
const stop = syncHostUserIdWithFirebaseAuth()
if (stop) stopHandles.push(stop)
}
describe('host user ID sync', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.authStore.isInitialized = false
hoisted.authStore.currentUser = null
})
afterEach(() => {
while (stopHandles.length) stopHandles.pop()?.()
delete window.__comfyDesktop2
})
it('waits for Firebase auth initialization before reporting a user', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'pending'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
it('reports a restored Firebase session immediately', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('reports an initially signed-out Firebase session', () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.isInitialized = true
startSync()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_out' }]
])
})
it('reports signed out when Firebase finishes initialization', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
startSync()
expect(reportFirebaseAuthState).toHaveBeenCalledOnce()
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
})
it('reports account switches, logout, and subsequent login', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-b' }
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-b'
})
hoisted.authStore.currentUser = null
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_out'
})
hoisted.authStore.currentUser = { uid: 'firebase-user-c' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }],
[{ status: 'signed_in', userId: 'firebase-user-b' }],
[{ status: 'signed_out' }],
[{ status: 'signed_in', userId: 'firebase-user-c' }]
])
})
it('does not report again when Firebase replaces the user object with the same UID', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
startSync()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
await nextTick()
expect(reportFirebaseAuthState.mock.calls).toEqual([
[{ status: 'pending' }],
[{ status: 'signed_in', userId: 'firebase-user-a' }]
])
})
it('does not let a host reporting failure interrupt Firebase state sync', async () => {
const { reportFirebaseAuthState } = installTelemetryBridge()
reportFirebaseAuthState.mockImplementationOnce(() => {
throw new Error('host unavailable')
})
expect(() => startSync()).not.toThrow()
hoisted.authStore.currentUser = { uid: 'firebase-user-a' }
hoisted.authStore.isInitialized = true
await nextTick()
expect(reportFirebaseAuthState).toHaveBeenLastCalledWith({
status: 'signed_in',
userId: 'firebase-user-a'
})
})
})

View File

@@ -1,49 +0,0 @@
import { watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useAuthStore } from '@/stores/authStore'
function safelyReportFirebaseAuthState(report: () => void): void {
try {
report()
} catch {
// A host bridge failure must not block renderer startup or Firebase auth.
}
}
/**
* Keep the Desktop main-process telemetry identity aligned with Firebase auth.
* Must run after Pinia and VueFire are installed.
*/
export function syncHostUserIdWithFirebaseAuth(): WatchStopHandle | undefined {
const telemetry = window.__comfyDesktop2?.Telemetry
if (!telemetry) return
// Register this Cloud renderer before Firebase resolves. Desktop may host
// multiple Cloud main frames whose isolated browser partitions have
// different auth states, so main owns all cross-WebContents arbitration.
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.({ status: 'pending' })
)
const authStore = useAuthStore()
return watch(
() =>
authStore.isInitialized
? (authStore.currentUser?.uid ?? null)
: undefined,
(userId) => {
if (userId === undefined) return
safelyReportFirebaseAuthState(() =>
telemetry.reportFirebaseAuthState?.(
userId === null
? { status: 'signed_out' }
: { status: 'signed_in', userId }
)
)
},
{ immediate: true }
)
}

View File

@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const analytics = {
identify: vi.fn().mockResolvedValue(undefined),
page: vi.fn(),
track: vi.fn().mockResolvedValue(undefined),
reset: vi.fn(),
register: vi.fn().mockResolvedValue(undefined)
@@ -109,89 +108,6 @@ describe('CustomerIoTelemetryProvider', () => {
expect(hoisted.analytics.register).toHaveBeenCalled()
})
it('reports the current page after registering the in-app plugin', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
expect(hoisted.analytics.register.mock.invocationCallOrder[0]).toBeLessThan(
hoisted.analytics.page.mock.invocationCallOrder[0]
)
})
it('queues page views until the in-app plugin is registered', async () => {
let resolveRegistration: (() => void) | undefined
const registration = new Promise<void>((resolve) => {
resolveRegistration = resolve
})
hoisted.analytics.register.mockReturnValue(registration)
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).not.toHaveBeenCalled()
resolveRegistration?.()
await vi.waitFor(() =>
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
)
})
it('reports client-side route changes', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
expect(hoisted.analytics.page).not.toHaveBeenCalled()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(hoisted.analytics.page).toHaveBeenCalledWith()
})
it('continues tracking events and page views when the in-app plugin fails to register', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const registrationError = new Error('in-app setup failed')
hoisted.analytics.register.mockRejectedValue(registrationError)
const provider = createProvider()
provider.trackWorkflowExecution()
provider.trackPageView('workflow_editor', {
path: 'https://cloud.comfy.org/'
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'execution_start',
SOURCE
)
expect(hoisted.analytics.page).toHaveBeenCalledOnce()
expect(consoleError).toHaveBeenCalledWith(
'Failed to initialize Customer.io in-app plugin:',
registrationError
)
provider.trackAddApiCreditButtonClicked()
await vi.waitFor(() =>
expect(hoisted.analytics.track).toHaveBeenCalledWith(
'app:add_api_credit_button_clicked',
SOURCE
)
)
provider.trackPageView('settings', {
path: 'https://cloud.comfy.org/settings'
})
expect(hoisted.analytics.page).toHaveBeenCalledTimes(2)
})
it('does not initialize without a write key', async () => {
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
await vi.dynamicImportSettled()

View File

@@ -8,7 +8,6 @@ import { TelemetryEvents } from '../../types'
import type {
AuthMetadata,
ExecutionSuccessMetadata,
PageViewMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
@@ -42,9 +41,7 @@ interface CustomerIoIdentity {
export class CustomerIoTelemetryProvider implements TelemetryProvider {
private analytics: AnalyticsBrowser | null = null
private isEnabled = true
private isPageViewTrackingReady = false
private eventQueue: QueuedEvent[] = []
private pageViewQueued = false
private identifiedUser: CustomerIoIdentity | null = null
private sessionIdentity: CustomerIoIdentity | null = null
private operationQueue: Promise<void> = Promise.resolve()
@@ -64,7 +61,7 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
void import('@customerio/cdp-analytics-browser')
.then(({ AnalyticsBrowser, InAppPlugin }) => {
const analytics = AnalyticsBrowser.load({ writeKey })
const inAppRegistration = analytics.register(
void analytics.register(
InAppPlugin({
siteId,
events: null,
@@ -100,17 +97,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
void this.flushQueue()
void inAppRegistration
.catch((error) => {
console.error(
'Failed to initialize Customer.io in-app plugin:',
error
)
})
.finally(() => {
this.isPageViewTrackingReady = true
this.flushPageView()
})
})
.catch((error) => {
console.error('Failed to load Customer.io:', error)
@@ -212,29 +198,6 @@ export class CustomerIoTelemetryProvider implements TelemetryProvider {
})
}
private sendPageView(): void {
void this.analytics?.page()?.catch((error) => {
console.error('Failed to track Customer.io page view:', error)
})
}
private flushPageView(): void {
if (!this.isPageViewTrackingReady || !this.pageViewQueued) {
return
}
this.pageViewQueued = false
this.sendPageView()
}
trackPageView(_pageName: string, _properties?: PageViewMetadata): void {
if (!this.isEnabled) return
if (!this.isPageViewTrackingReady) {
this.pageViewQueued = true
return
}
this.sendPageView()
}
trackAuth(metadata: AuthMetadata): void {
const identity = metadata.user_id
? {

View File

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