mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 09:18:26 +00:00
Compare commits
1 Commits
feat/dashb
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e893bf0eba |
78
.github/workflows/ci-dashboard-deploy.yaml
vendored
78
.github/workflows/ci-dashboard-deploy.yaml
vendored
@@ -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
|
||||
@@ -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(/\/$/, '')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
219
src/components/cameraInfo/CameraInfo.vue
Normal file
219
src/components/cameraInfo/CameraInfo.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative size-full min-h-[300px]"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
>
|
||||
<div
|
||||
ref="container"
|
||||
class="relative size-full"
|
||||
data-capture-wheel="true"
|
||||
tabindex="-1"
|
||||
@pointerdown.stop="focusContainer"
|
||||
@contextmenu.stop.prevent
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0">
|
||||
<div
|
||||
ref="toolbar"
|
||||
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<button
|
||||
v-tooltip.bottom="tip(gizmosLabel)"
|
||||
type="button"
|
||||
:disabled="lookingThrough"
|
||||
:class="
|
||||
cn(
|
||||
actionClass(!lookingThrough && gizmosOn),
|
||||
lookingThrough && 'cursor-not-allowed opacity-40'
|
||||
)
|
||||
"
|
||||
:aria-pressed="!lookingThrough && gizmosOn"
|
||||
:aria-label="compact ? gizmosLabel : undefined"
|
||||
@click="toggleGizmos"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'size-4',
|
||||
gizmosOn ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span v-if="!compact">{{ gizmosLabel }}</span>
|
||||
</button>
|
||||
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
|
||||
<button
|
||||
v-for="option in transformGizmoOptions"
|
||||
:key="option.value"
|
||||
v-tooltip.bottom="tip($t(option.labelKey))"
|
||||
type="button"
|
||||
:disabled="lookingThrough || !option.enabled"
|
||||
:aria-pressed="!lookingThrough && transformGizmoMode === option.value"
|
||||
:aria-label="compact ? $t(option.labelKey) : undefined"
|
||||
:class="
|
||||
cn(
|
||||
actionClass(
|
||||
!lookingThrough && transformGizmoMode === option.value
|
||||
),
|
||||
(lookingThrough || !option.enabled) &&
|
||||
'cursor-not-allowed opacity-40'
|
||||
)
|
||||
"
|
||||
@click="selectTransformGizmo(option.value)"
|
||||
>
|
||||
<i :class="cn('size-4', option.icon)" />
|
||||
<span v-if="!compact">{{ $t(option.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0">
|
||||
<div
|
||||
class="pointer-events-auto flex h-10 items-center justify-end gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<button
|
||||
v-tooltip.top="tip(lookThroughLabel)"
|
||||
type="button"
|
||||
:class="
|
||||
cn(iconBtnClass, lookingThrough && 'bg-button-active-surface')
|
||||
"
|
||||
:aria-pressed="lookingThrough"
|
||||
:aria-label="lookThroughLabel"
|
||||
@click="toggleLookThrough"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
iconBtnClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import { useCameraInfo } from '@/composables/useCameraInfo'
|
||||
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComponentWidget } from '@/scripts/domWidget'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { widget, nodeId } = defineProps<{
|
||||
widget: ComponentWidget<string[]> | SimplifiedWidget
|
||||
nodeId?: NodeId
|
||||
}>()
|
||||
|
||||
function isComponentWidget(
|
||||
w: ComponentWidget<string[]> | SimplifiedWidget
|
||||
): w is ComponentWidget<string[]> {
|
||||
return 'node' in w && w.node !== undefined
|
||||
}
|
||||
|
||||
const node = ref<LGraphNode | null>(null)
|
||||
if (isComponentWidget(widget)) {
|
||||
node.value = widget.node
|
||||
} else if (nodeId) {
|
||||
onMounted(() => {
|
||||
node.value = resolveNode(nodeId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const toolbar = ref<HTMLElement | null>(null)
|
||||
const { width: toolbarWidth } = useElementSize(toolbar)
|
||||
const compactWidthThreshold = 480
|
||||
const compact = computed(
|
||||
() => toolbarWidth.value > 0 && toolbarWidth.value < compactWidthThreshold
|
||||
)
|
||||
const gizmosOn = ref(true)
|
||||
const lookingThrough = ref(false)
|
||||
const transformGizmoMode = ref<TransformGizmoMode>('none')
|
||||
const {
|
||||
initialize,
|
||||
cleanup,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setGizmosVisible,
|
||||
setTransformGizmoMode,
|
||||
setLookThrough,
|
||||
mode
|
||||
} = useCameraInfo(node as Ref<LGraphNode | null>)
|
||||
|
||||
const gizmosLabel = computed(() =>
|
||||
gizmosOn.value ? t('load3d.hideGizmos') : t('load3d.showGizmos')
|
||||
)
|
||||
|
||||
const lookThroughLabel = computed(() =>
|
||||
lookingThrough.value ? t('load3d.exitLookThrough') : t('load3d.lookThrough')
|
||||
)
|
||||
|
||||
const transformGizmoOptions = computed(() => [
|
||||
{
|
||||
value: 'none' as const,
|
||||
labelKey: 'load3d.transformGizmo.none',
|
||||
icon: 'icon-[lucide--ban]',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
value: 'target' as const,
|
||||
labelKey: 'load3d.transformGizmo.target',
|
||||
icon: 'icon-[lucide--target]',
|
||||
enabled: mode.value === 'orbit' || mode.value === 'look_at'
|
||||
},
|
||||
{
|
||||
value: 'camera-translate' as const,
|
||||
labelKey: 'load3d.transformGizmo.cameraTranslate',
|
||||
icon: 'icon-[lucide--move-3d]',
|
||||
enabled: mode.value === 'look_at' || mode.value === 'quaternion'
|
||||
},
|
||||
{
|
||||
value: 'camera-rotate' as const,
|
||||
labelKey: 'load3d.transformGizmo.cameraRotate',
|
||||
icon: 'icon-[lucide--rotate-3d]',
|
||||
enabled: mode.value === 'quaternion'
|
||||
}
|
||||
])
|
||||
|
||||
function focusContainer() {
|
||||
container.value?.focus()
|
||||
}
|
||||
|
||||
function toggleGizmos() {
|
||||
gizmosOn.value = !gizmosOn.value
|
||||
}
|
||||
|
||||
function toggleLookThrough() {
|
||||
lookingThrough.value = !lookingThrough.value
|
||||
}
|
||||
|
||||
function selectTransformGizmo(value: TransformGizmoMode) {
|
||||
transformGizmoMode.value = value
|
||||
}
|
||||
|
||||
watch(gizmosOn, (on) => setGizmosVisible(on))
|
||||
watch(transformGizmoMode, (m) => setTransformGizmoMode(m))
|
||||
watch(lookingThrough, (on) => setLookThrough(on))
|
||||
|
||||
onMounted(() => {
|
||||
if (container.value) initialize(container.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
@@ -72,6 +72,8 @@
|
||||
v-if="showCameraControls"
|
||||
v-model:camera-type="cameraConfig!.cameraType"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
v-model:use-custom-up="cameraConfig!.useCustomUp"
|
||||
:has-custom-up="cameraConfig!.hasCustomUp ?? false"
|
||||
/>
|
||||
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
|
||||
@@ -13,6 +13,33 @@
|
||||
>
|
||||
<i class="pi pi-camera text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasCustomUp"
|
||||
v-tooltip.right="{
|
||||
value: useCustomUp
|
||||
? $t('load3d.useNaturalUp')
|
||||
: $t('load3d.useCustomUp'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="
|
||||
useCustomUp ? $t('load3d.useNaturalUp') : $t('load3d.useCustomUp')
|
||||
"
|
||||
@click="toggleUp"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
useCustomUp
|
||||
? 'icon-[lucide--compass]'
|
||||
: 'icon-[lucide--rotate-ccw]',
|
||||
'text-lg text-base-foreground'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
<PopupSlider
|
||||
v-if="showFOVButton"
|
||||
v-model="fov"
|
||||
@@ -27,13 +54,24 @@ import { computed } from 'vue'
|
||||
import PopupSlider from '@/components/load3d/controls/PopupSlider.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { CameraType } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { hasCustomUp = false } = defineProps<{
|
||||
hasCustomUp?: boolean
|
||||
}>()
|
||||
|
||||
const cameraType = defineModel<CameraType>('cameraType')
|
||||
const fov = defineModel<number>('fov')
|
||||
const useCustomUp = defineModel<boolean>('useCustomUp', { default: false })
|
||||
|
||||
const showFOVButton = computed(() => cameraType.value === 'perspective')
|
||||
|
||||
const switchCamera = () => {
|
||||
cameraType.value =
|
||||
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
const toggleUp = () => {
|
||||
useCustomUp.value = !useCustomUp.value
|
||||
}
|
||||
</script>
|
||||
|
||||
209
src/composables/useCameraInfo.test.ts
Normal file
209
src/composables/useCameraInfo.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
interface ViewportInstance {
|
||||
ctorArgs: unknown[]
|
||||
applyState: ReturnType<typeof vi.fn>
|
||||
setGizmosVisible: ReturnType<typeof vi.fn>
|
||||
setTransformGizmoMode: ReturnType<typeof vi.fn>
|
||||
setLookThrough: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
viewport: {
|
||||
updateStatusMouseOnScene: ReturnType<typeof vi.fn>
|
||||
updateStatusMouseOnNode: ReturnType<typeof vi.fn>
|
||||
refreshViewport: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
const { ViewportMock, instances, addAlert } = vi.hoisted(() => {
|
||||
const instances: ViewportInstance[] = []
|
||||
const ViewportMock = vi.fn(function (...ctorArgs: unknown[]) {
|
||||
const instance: ViewportInstance = {
|
||||
ctorArgs,
|
||||
applyState: vi.fn(),
|
||||
setGizmosVisible: vi.fn(),
|
||||
setTransformGizmoMode: vi.fn(),
|
||||
setLookThrough: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
viewport: {
|
||||
updateStatusMouseOnScene: vi.fn(),
|
||||
updateStatusMouseOnNode: vi.fn(),
|
||||
refreshViewport: vi.fn()
|
||||
}
|
||||
}
|
||||
instances.push(instance)
|
||||
return instance
|
||||
})
|
||||
return { ViewportMock, instances, addAlert: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/core/cameraInfo/CameraInfoViewport', () => ({
|
||||
CameraInfoViewport: ViewportMock
|
||||
}))
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert })
|
||||
}))
|
||||
|
||||
import { useCameraInfo } from './useCameraInfo'
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
callback?: (value: unknown, ...rest: unknown[]) => void
|
||||
}
|
||||
|
||||
interface FakeNode {
|
||||
widgets: FakeWidget[]
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
|
||||
function makeNode(values: Record<string, unknown>): FakeNode {
|
||||
return {
|
||||
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
}
|
||||
|
||||
function widget(node: FakeNode, name: string): FakeWidget {
|
||||
const found = node.widgets.find((w) => w.name === name)
|
||||
if (!found) throw new Error(`missing widget ${name}`)
|
||||
return found
|
||||
}
|
||||
|
||||
function nodeRef(node: FakeNode) {
|
||||
return ref(node) as unknown as Ref<LGraphNode | null>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
instances.length = 0
|
||||
ViewportMock.mockClear()
|
||||
addAlert.mockClear()
|
||||
})
|
||||
|
||||
describe('useCameraInfo', () => {
|
||||
it('constructs the viewport from widget state and exposes the mode', () => {
|
||||
const node = makeNode({ mode: 'look_at', 'mode.distance': 7 })
|
||||
const container = document.createElement('div')
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
|
||||
camera.initialize(container)
|
||||
|
||||
expect(ViewportMock).toHaveBeenCalledOnce()
|
||||
const [ctorContainer, initialState] = instances[0].ctorArgs as [
|
||||
HTMLElement,
|
||||
{ mode: string; orbit: { distance: number } }
|
||||
]
|
||||
expect(ctorContainer).toBe(container)
|
||||
expect(initialState.mode).toBe('look_at')
|
||||
expect(initialState.orbit.distance).toBe(7)
|
||||
expect(camera.mode.value).toBe('look_at')
|
||||
})
|
||||
|
||||
it('does nothing when the node is null', () => {
|
||||
const camera = useCameraInfo(ref(null))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
expect(ViewportMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('alerts and does not throw when the viewport fails to construct', () => {
|
||||
ViewportMock.mockImplementationOnce(() => {
|
||||
throw new Error('webgl unavailable')
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
|
||||
|
||||
expect(() => camera.initialize(document.createElement('div'))).not.toThrow()
|
||||
expect(addAlert).toHaveBeenCalledOnce()
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('forwards toolbar actions to the viewport', () => {
|
||||
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
camera.setGizmosVisible(false)
|
||||
camera.setTransformGizmoMode('camera-rotate')
|
||||
camera.setLookThrough(true)
|
||||
|
||||
expect(instances[0].setGizmosVisible).toHaveBeenCalledWith(false)
|
||||
expect(instances[0].setTransformGizmoMode).toHaveBeenCalledWith(
|
||||
'camera-rotate'
|
||||
)
|
||||
expect(instances[0].setLookThrough).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('ignores toolbar actions before initialization', () => {
|
||||
const camera = useCameraInfo(nodeRef(makeNode({ mode: 'orbit' })))
|
||||
|
||||
expect(() => camera.setLookThrough(true)).not.toThrow()
|
||||
expect(instances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('re-applies state to the viewport when a widget changes, keeping the original callback', () => {
|
||||
const node = makeNode({ mode: 'orbit', target_x: 0 })
|
||||
const original = vi.fn()
|
||||
widget(node, 'target_x').callback = original
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
const targetX = widget(node, 'target_x')
|
||||
targetX.value = 3
|
||||
targetX.callback!(3)
|
||||
|
||||
expect(original).toHaveBeenCalledWith(3)
|
||||
const applied = instances[0].applyState.mock.lastCall?.[0] as {
|
||||
target: { x: number }
|
||||
}
|
||||
expect(applied.target.x).toBe(3)
|
||||
})
|
||||
|
||||
it('updates the mode ref when the mode widget changes', () => {
|
||||
const node = makeNode({ mode: 'orbit' })
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
const modeWidget = widget(node, 'mode')
|
||||
modeWidget.value = 'quaternion'
|
||||
modeWidget.callback!('quaternion')
|
||||
|
||||
expect(camera.mode.value).toBe('quaternion')
|
||||
})
|
||||
|
||||
it('routes node hover into the viewport status flags', () => {
|
||||
const node = makeNode({ mode: 'orbit' })
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
camera.handleMouseEnter()
|
||||
camera.handleMouseLeave()
|
||||
node.onMouseEnter?.()
|
||||
|
||||
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
|
||||
true
|
||||
)
|
||||
expect(instances[0].viewport.updateStatusMouseOnScene).toHaveBeenCalledWith(
|
||||
false
|
||||
)
|
||||
expect(instances[0].viewport.updateStatusMouseOnNode).toHaveBeenCalledWith(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('removes the viewport and restores widget callbacks on cleanup', () => {
|
||||
const node = makeNode({ mode: 'orbit', target_x: 0 })
|
||||
const original = vi.fn()
|
||||
widget(node, 'target_x').callback = original
|
||||
const camera = useCameraInfo(nodeRef(node))
|
||||
camera.initialize(document.createElement('div'))
|
||||
|
||||
camera.cleanup()
|
||||
|
||||
expect(instances[0].remove).toHaveBeenCalledOnce()
|
||||
expect(widget(node, 'target_x').callback).toBe(original)
|
||||
})
|
||||
})
|
||||
151
src/composables/useCameraInfo.ts
Normal file
151
src/composables/useCameraInfo.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ref, toRaw, toRef } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { CameraInfoViewport } from '@/extensions/core/cameraInfo/CameraInfoViewport'
|
||||
import type { TransformGizmoMode } from '@/extensions/core/cameraInfo/CameraInfoViewport'
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from '@/extensions/core/cameraInfo/types'
|
||||
import type { CameraInfoMode } from '@/extensions/core/cameraInfo/types'
|
||||
import {
|
||||
readStateFromWidgets,
|
||||
writeWidgetValue
|
||||
} from '@/extensions/core/cameraInfo/widgetBridge'
|
||||
import type { NodeWithWidgets } from '@/extensions/core/cameraInfo/widgetBridge'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
type WidgetCallback = (value: unknown, ...rest: unknown[]) => void
|
||||
interface MutableWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
callback?: WidgetCallback
|
||||
}
|
||||
|
||||
const WIDGET_NAMES = [
|
||||
'mode',
|
||||
'camera_type',
|
||||
'target_x',
|
||||
'target_y',
|
||||
'target_z',
|
||||
'roll',
|
||||
'fov',
|
||||
'zoom',
|
||||
'mode.yaw',
|
||||
'mode.pitch',
|
||||
'mode.distance',
|
||||
'mode.position_x',
|
||||
'mode.position_y',
|
||||
'mode.position_z',
|
||||
'mode.quat_x',
|
||||
'mode.quat_y',
|
||||
'mode.quat_z',
|
||||
'mode.quat_w'
|
||||
] as const
|
||||
|
||||
export function useCameraInfo(nodeRef: MaybeRef<LGraphNode | null>) {
|
||||
const node = toRef(nodeRef)
|
||||
let viewport: CameraInfoViewport | null = null
|
||||
|
||||
const mode = ref<CameraInfoMode>(DEFAULT_CAMERA_INFO_STATE.mode)
|
||||
|
||||
const wrappedWidgets: { widget: MutableWidget; original?: WidgetCallback }[] =
|
||||
[]
|
||||
const wrappedSet = new WeakSet<MutableWidget>()
|
||||
|
||||
const initialize = (container: HTMLElement): void => {
|
||||
const raw = toRaw(node.value)
|
||||
if (!raw || !container) return
|
||||
|
||||
try {
|
||||
const initialState = readStateFromWidgets(raw as NodeWithWidgets)
|
||||
mode.value = initialState.mode
|
||||
viewport = new CameraInfoViewport(container, initialState, {
|
||||
onHandleDrag: (fieldName, value) => {
|
||||
writeWidgetValue(raw as NodeWithWidgets, fieldName, value)
|
||||
}
|
||||
})
|
||||
wireWidgetsToOverlay(raw as NodeWithWidgets)
|
||||
wireNodeMouseStatus(raw as LGraphNode)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize CameraInfoViewport:', error)
|
||||
useToastStore().addAlert('Failed to initialize Camera Info viewport.')
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
unwireWidgets()
|
||||
viewport?.remove()
|
||||
viewport = null
|
||||
}
|
||||
|
||||
const handleMouseEnter = (): void => {
|
||||
viewport?.viewport.updateStatusMouseOnScene(true)
|
||||
viewport?.viewport.refreshViewport()
|
||||
}
|
||||
|
||||
const handleMouseLeave = (): void => {
|
||||
viewport?.viewport.updateStatusMouseOnScene(false)
|
||||
}
|
||||
|
||||
const setGizmosVisible = (on: boolean): void => {
|
||||
viewport?.setGizmosVisible(on)
|
||||
}
|
||||
|
||||
const setTransformGizmoMode = (gizmoMode: TransformGizmoMode): void => {
|
||||
viewport?.setTransformGizmoMode(gizmoMode)
|
||||
}
|
||||
|
||||
const setLookThrough = (on: boolean): void => {
|
||||
viewport?.setLookThrough(on)
|
||||
}
|
||||
|
||||
function wireNodeMouseStatus(target: LGraphNode): void {
|
||||
target.onMouseEnter = useChainCallback(target.onMouseEnter, () => {
|
||||
viewport?.viewport.updateStatusMouseOnNode(true)
|
||||
viewport?.viewport.refreshViewport()
|
||||
})
|
||||
target.onMouseLeave = useChainCallback(target.onMouseLeave, () => {
|
||||
viewport?.viewport.updateStatusMouseOnNode(false)
|
||||
})
|
||||
}
|
||||
|
||||
function wireWidgetsToOverlay(target: NodeWithWidgets): void {
|
||||
if (!target.widgets) return
|
||||
for (const name of WIDGET_NAMES) {
|
||||
const widget = target.widgets.find(
|
||||
(w): w is MutableWidget => w.name === name
|
||||
)
|
||||
if (!widget || wrappedSet.has(widget)) continue
|
||||
wrappedSet.add(widget)
|
||||
const original = widget.callback
|
||||
const isModeWidget = widget.name === 'mode'
|
||||
wrappedWidgets.push({ widget, original })
|
||||
widget.callback = (value, ...rest) => {
|
||||
original?.call(widget, value, ...rest)
|
||||
if (isModeWidget) wireWidgetsToOverlay(target)
|
||||
if (!viewport) return
|
||||
const state = readStateFromWidgets(target)
|
||||
mode.value = state.mode
|
||||
viewport.applyState(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unwireWidgets(): void {
|
||||
for (const { widget, original } of wrappedWidgets) {
|
||||
widget.callback = original
|
||||
}
|
||||
wrappedWidgets.length = 0
|
||||
}
|
||||
|
||||
return {
|
||||
initialize,
|
||||
cleanup,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
setGizmosVisible,
|
||||
setTransformGizmoMode,
|
||||
setLookThrough,
|
||||
mode
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const cameraConfig = ref<CameraConfig>({
|
||||
cameraType: 'perspective',
|
||||
fov: 75
|
||||
fov: 75,
|
||||
hasCustomUp: false,
|
||||
useCustomUp: false
|
||||
})
|
||||
|
||||
const lightConfig = ref<LightConfig>({
|
||||
@@ -534,6 +536,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
nodeRef.value.properties['Camera Config'] = newValue
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
if (newValue.hasCustomUp) {
|
||||
load3d.setUseCustomUp(newValue.useCustomUp ?? false)
|
||||
}
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
@@ -871,6 +876,13 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
cameraTypeChange: (value: string) => {
|
||||
cameraConfig.value.cameraType = value as CameraType
|
||||
},
|
||||
cameraUpStateChange: (value: {
|
||||
hasCustomUp: boolean
|
||||
usingCustomUp: boolean
|
||||
}) => {
|
||||
cameraConfig.value.hasCustomUp = value.hasCustomUp
|
||||
cameraConfig.value.useCustomUp = value.usingCustomUp
|
||||
},
|
||||
showGridChange: (value: boolean) => {
|
||||
sceneConfig.value.showGrid = value
|
||||
},
|
||||
|
||||
40
src/extensions/core/cameraInfo.ts
Normal file
40
src/extensions/core/cameraInfo.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import CameraInfo from '@/components/cameraInfo/CameraInfo.vue'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateCameraInfo',
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
CAMERA_INFO_STATE(node) {
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node,
|
||||
name: 'camera_info_state',
|
||||
component: CameraInfo,
|
||||
inputSpec: {
|
||||
name: 'camera_info_state',
|
||||
type: 'CAMERA_INFO_STATE',
|
||||
isPreview: false
|
||||
},
|
||||
options: {}
|
||||
})
|
||||
widget.type = 'cameraInfo'
|
||||
addWidget(node, widget)
|
||||
return { widget }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'CreateCameraInfo') return
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
node.setSize([Math.max(oldWidth, 360), Math.max(oldHeight, 480)])
|
||||
|
||||
await nextTick()
|
||||
}
|
||||
})
|
||||
148
src/extensions/core/cameraInfo/CameraInfoOverlay.test.ts
Normal file
148
src/extensions/core/cameraInfo/CameraInfoOverlay.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { CameraInfoOverlay } from './CameraInfoOverlay'
|
||||
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
|
||||
|
||||
function setupOverlay(initial?: Partial<CameraInfoState>) {
|
||||
const overlay = new CameraInfoOverlay({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
...initial
|
||||
})
|
||||
const scene = new THREE.Scene()
|
||||
overlay.attach(scene)
|
||||
return { overlay, scene }
|
||||
}
|
||||
|
||||
function findByName(scene: THREE.Scene, name: string): THREE.Object3D | null {
|
||||
for (const child of scene.children) {
|
||||
if (child.name === name) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
describe('CameraInfoOverlay', () => {
|
||||
let ctx: ReturnType<typeof setupOverlay>
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = setupOverlay()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
ctx.overlay.dispose()
|
||||
})
|
||||
|
||||
describe('attach / detach', () => {
|
||||
it('adds reference + subject cameras + camera helper to the scene', () => {
|
||||
expect(findByName(ctx.scene, 'CameraInfoReference')).not.toBeNull()
|
||||
const subjectCameras = ctx.scene.children.filter(
|
||||
(c) =>
|
||||
c instanceof THREE.PerspectiveCamera ||
|
||||
c instanceof THREE.OrthographicCamera
|
||||
)
|
||||
expect(subjectCameras).toHaveLength(2)
|
||||
expect(
|
||||
ctx.scene.children.some((c) => c instanceof THREE.CameraHelper)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('detach removes everything the overlay added', () => {
|
||||
ctx.overlay.detach()
|
||||
|
||||
expect(findByName(ctx.scene, 'CameraInfoReference')).toBeNull()
|
||||
expect(
|
||||
ctx.scene.children.some(
|
||||
(c) =>
|
||||
c instanceof THREE.PerspectiveCamera ||
|
||||
c instanceof THREE.OrthographicCamera ||
|
||||
c instanceof THREE.CameraHelper
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyState — orbit mode', () => {
|
||||
it('positions the active subject camera per yaw / pitch / distance', () => {
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const cam = ctx.overlay.getSubjectCamera()
|
||||
expect(cam.position.x).toBeCloseTo(0)
|
||||
expect(cam.position.y).toBeCloseTo(0)
|
||||
expect(cam.position.z).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('updates fov on the perspective subject camera', () => {
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
fov: 70
|
||||
})
|
||||
|
||||
const cam = ctx.overlay.getSubjectCamera() as THREE.PerspectiveCamera
|
||||
expect(cam.fov).toBe(70)
|
||||
})
|
||||
})
|
||||
|
||||
describe('camera type swap', () => {
|
||||
it('toggling to orthographic swaps the active subject camera and rebuilds the helper', () => {
|
||||
const initialHelper = ctx.scene.children.find(
|
||||
(c) => c instanceof THREE.CameraHelper
|
||||
)
|
||||
|
||||
ctx.overlay.applyState({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
|
||||
expect(ctx.overlay.getSubjectCamera()).toBeInstanceOf(
|
||||
THREE.OrthographicCamera
|
||||
)
|
||||
const newHelper = ctx.scene.children.find(
|
||||
(c) => c instanceof THREE.CameraHelper
|
||||
)
|
||||
expect(newHelper).toBeDefined()
|
||||
expect(newHelper).not.toBe(initialHelper)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POV (onActiveCameraChange)', () => {
|
||||
it('hides the camera helper when the render camera IS the subject camera', () => {
|
||||
const subjectCam = ctx.overlay.getSubjectCamera()
|
||||
|
||||
ctx.overlay.onActiveCameraChange(subjectCam)
|
||||
|
||||
const helper = ctx.scene.children.find(
|
||||
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
|
||||
)!
|
||||
expect(helper.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the camera helper when render camera is some other camera', () => {
|
||||
const subjectCam = ctx.overlay.getSubjectCamera()
|
||||
ctx.overlay.onActiveCameraChange(subjectCam)
|
||||
const otherCam = new THREE.PerspectiveCamera()
|
||||
|
||||
ctx.overlay.onActiveCameraChange(otherCam)
|
||||
|
||||
const helper = ctx.scene.children.find(
|
||||
(c): c is THREE.CameraHelper => c instanceof THREE.CameraHelper
|
||||
)!
|
||||
expect(helper.visible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getState immutability', () => {
|
||||
it('returns a deep clone the caller cannot mutate to affect overlay', () => {
|
||||
const snapshot = ctx.overlay.getState()
|
||||
snapshot.orbit.yaw = 999
|
||||
|
||||
const next = ctx.overlay.getState()
|
||||
expect(next.orbit.yaw).not.toBe(999)
|
||||
expect(next.target).not.toBe(snapshot.target)
|
||||
})
|
||||
})
|
||||
})
|
||||
213
src/extensions/core/cameraInfo/CameraInfoOverlay.ts
Normal file
213
src/extensions/core/cameraInfo/CameraInfoOverlay.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { SceneOverlay } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import {
|
||||
DEFAULT_CAMERA_INFO_STATE,
|
||||
type CameraInfoCameraType,
|
||||
type CameraInfoState
|
||||
} from './types'
|
||||
|
||||
const REFERENCE_CUBE_SIZE = 1
|
||||
const SUBJECT_CAMERA_NEAR = 0.1
|
||||
const SUBJECT_CAMERA_FAR = 1000
|
||||
const ORTHO_FRUSTUM_HALF = 1
|
||||
|
||||
export class CameraInfoOverlay implements SceneOverlay {
|
||||
private scene: THREE.Scene | null = null
|
||||
private state: CameraInfoState
|
||||
|
||||
private readonly subjectPerspective: THREE.PerspectiveCamera
|
||||
private readonly subjectOrthographic: THREE.OrthographicCamera
|
||||
private subjectCamera: THREE.Camera
|
||||
private cameraHelper: THREE.CameraHelper | null = null
|
||||
|
||||
private readonly referenceGroup: THREE.Group
|
||||
private readonly referenceCube: THREE.Mesh
|
||||
private readonly referenceCubeEdges: THREE.LineSegments
|
||||
private readonly axesHelper: THREE.AxesHelper
|
||||
|
||||
private renderCamera: THREE.Camera | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE) {
|
||||
this.state = cloneState(initialState)
|
||||
|
||||
this.subjectPerspective = new THREE.PerspectiveCamera(
|
||||
this.state.fov,
|
||||
1,
|
||||
SUBJECT_CAMERA_NEAR,
|
||||
SUBJECT_CAMERA_FAR
|
||||
)
|
||||
this.subjectOrthographic = new THREE.OrthographicCamera(
|
||||
-ORTHO_FRUSTUM_HALF,
|
||||
ORTHO_FRUSTUM_HALF,
|
||||
ORTHO_FRUSTUM_HALF,
|
||||
-ORTHO_FRUSTUM_HALF,
|
||||
SUBJECT_CAMERA_NEAR,
|
||||
SUBJECT_CAMERA_FAR
|
||||
)
|
||||
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
|
||||
|
||||
const cubeGeometry = new THREE.BoxGeometry(
|
||||
REFERENCE_CUBE_SIZE,
|
||||
REFERENCE_CUBE_SIZE,
|
||||
REFERENCE_CUBE_SIZE
|
||||
)
|
||||
this.referenceCube = new THREE.Mesh(
|
||||
cubeGeometry,
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0xb8bcc4,
|
||||
roughness: 0.55,
|
||||
metalness: 0.15
|
||||
})
|
||||
)
|
||||
this.referenceCube.position.set(0, REFERENCE_CUBE_SIZE / 2, 0)
|
||||
|
||||
this.referenceCubeEdges = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(cubeGeometry),
|
||||
new THREE.LineBasicMaterial({
|
||||
color: 0x1a1a1a,
|
||||
transparent: true,
|
||||
opacity: 0.4
|
||||
})
|
||||
)
|
||||
this.referenceCube.add(this.referenceCubeEdges)
|
||||
|
||||
this.axesHelper = new THREE.AxesHelper(REFERENCE_CUBE_SIZE * 1.25)
|
||||
|
||||
this.referenceGroup = new THREE.Group()
|
||||
this.referenceGroup.name = 'CameraInfoReference'
|
||||
this.referenceGroup.add(this.referenceCube)
|
||||
this.referenceGroup.add(this.axesHelper)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.referenceGroup)
|
||||
scene.add(this.subjectPerspective)
|
||||
scene.add(this.subjectOrthographic)
|
||||
this.rebuildCameraHelper()
|
||||
this.applyStateToScene()
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.referenceGroup)
|
||||
this.scene.remove(this.subjectPerspective)
|
||||
this.scene.remove(this.subjectOrthographic)
|
||||
if (this.cameraHelper) this.scene.remove(this.cameraHelper)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
update(_delta: number): void {
|
||||
this.cameraHelper?.update()
|
||||
}
|
||||
|
||||
onActiveCameraChange(camera: THREE.Camera): void {
|
||||
this.renderCamera = camera
|
||||
this.refreshHelperVisibility()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.disposeCameraHelper()
|
||||
this.referenceCube.geometry.dispose()
|
||||
;(this.referenceCube.material as THREE.Material).dispose()
|
||||
this.referenceCubeEdges.geometry.dispose()
|
||||
;(this.referenceCubeEdges.material as THREE.Material).dispose()
|
||||
this.axesHelper.dispose()
|
||||
}
|
||||
|
||||
getSubjectCamera(): THREE.Camera {
|
||||
return this.subjectCamera
|
||||
}
|
||||
|
||||
setHelperVisible(visible: boolean): void {
|
||||
if (this.cameraHelper) this.cameraHelper.visible = visible
|
||||
}
|
||||
|
||||
getState(): CameraInfoState {
|
||||
return cloneState(this.state)
|
||||
}
|
||||
|
||||
applyState(next: CameraInfoState): void {
|
||||
const cameraTypeChanged = next.cameraType !== this.state.cameraType
|
||||
const modeChanged = next.mode !== this.state.mode
|
||||
this.state = cloneState(next)
|
||||
if (cameraTypeChanged) {
|
||||
this.subjectCamera = this.subjectCameraFor(this.state.cameraType)
|
||||
this.rebuildCameraHelper()
|
||||
}
|
||||
this.applyStateToScene()
|
||||
if (modeChanged || cameraTypeChanged) this.refreshHelperVisibility()
|
||||
}
|
||||
|
||||
private applyStateToScene(): void {
|
||||
const { position, quaternion } = computeSubjectTransform(this.state)
|
||||
this.subjectCamera.position.copy(position)
|
||||
this.subjectCamera.quaternion.copy(quaternion)
|
||||
this.subjectCamera.updateMatrixWorld(true)
|
||||
|
||||
if (this.subjectCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.subjectCamera.fov = this.state.fov
|
||||
this.subjectCamera.zoom = this.state.zoom
|
||||
this.subjectCamera.updateProjectionMatrix()
|
||||
} else if (this.subjectCamera instanceof THREE.OrthographicCamera) {
|
||||
this.subjectCamera.zoom = this.state.zoom
|
||||
this.subjectCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.cameraHelper?.update()
|
||||
}
|
||||
|
||||
private subjectCameraFor(type: CameraInfoCameraType): THREE.Camera {
|
||||
return type === 'perspective'
|
||||
? this.subjectPerspective
|
||||
: this.subjectOrthographic
|
||||
}
|
||||
|
||||
private rebuildCameraHelper(): void {
|
||||
this.disposeCameraHelper()
|
||||
if (!this.scene) return
|
||||
this.cameraHelper = new THREE.CameraHelper(this.subjectCamera)
|
||||
this.scene.add(this.cameraHelper)
|
||||
this.refreshHelperVisibility()
|
||||
}
|
||||
|
||||
private disposeCameraHelper(): void {
|
||||
if (!this.cameraHelper) return
|
||||
if (this.scene) this.scene.remove(this.cameraHelper)
|
||||
this.cameraHelper.geometry.dispose()
|
||||
const material = this.cameraHelper.material as
|
||||
| THREE.Material
|
||||
| THREE.Material[]
|
||||
if (Array.isArray(material)) material.forEach((m) => m.dispose())
|
||||
else material.dispose()
|
||||
this.cameraHelper = null
|
||||
}
|
||||
|
||||
private refreshHelperVisibility(): void {
|
||||
if (!this.cameraHelper) return
|
||||
this.cameraHelper.visible = this.renderCamera !== this.subjectCamera
|
||||
}
|
||||
}
|
||||
|
||||
function cloneState(state: CameraInfoState): CameraInfoState {
|
||||
return {
|
||||
mode: state.mode,
|
||||
target: { ...state.target },
|
||||
roll: state.roll,
|
||||
fov: state.fov,
|
||||
zoom: state.zoom,
|
||||
cameraType: state.cameraType,
|
||||
orbit: { ...state.orbit },
|
||||
lookAt: { position: { ...state.lookAt.position } },
|
||||
quaternion: {
|
||||
position: { ...state.quaternion.position },
|
||||
quat: { ...state.quaternion.quat }
|
||||
}
|
||||
}
|
||||
}
|
||||
305
src/extensions/core/cameraInfo/CameraInfoViewport.test.ts
Normal file
305
src/extensions/core/cameraInfo/CameraInfoViewport.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { createViewport3dMock } = vi.hoisted(() => ({
|
||||
createViewport3dMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/createViewport3d', () => ({
|
||||
createViewport3d: createViewport3dMock
|
||||
}))
|
||||
|
||||
import { CameraInfoViewport } from './CameraInfoViewport'
|
||||
|
||||
function makeViewportStub() {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 600
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
canvas.setPointerCapture = vi.fn()
|
||||
canvas.releasePointerCapture = vi.fn()
|
||||
canvas.hasPointerCapture = vi.fn(() => false)
|
||||
let postRender: (() => void) | null = null
|
||||
return {
|
||||
sceneManager: { scene: new THREE.Scene() },
|
||||
cameraManager: { activeCamera: new THREE.PerspectiveCamera() },
|
||||
controlsManager: { controls: { enabled: true } },
|
||||
viewHelperManager: { visibleViewHelper: vi.fn() },
|
||||
renderer: {
|
||||
setViewport: vi.fn(),
|
||||
setScissor: vi.fn(),
|
||||
setScissorTest: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn()
|
||||
},
|
||||
domElement: canvas,
|
||||
setOverlay: vi.fn(),
|
||||
addPostRenderCallback: vi.fn((cb: () => void) => {
|
||||
postRender = cb
|
||||
return vi.fn()
|
||||
}),
|
||||
setExternalActiveCamera: vi.fn(),
|
||||
forceRender: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
runPostRender: () => postRender?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('CameraInfoViewport look-through', () => {
|
||||
let stub: ReturnType<typeof makeViewportStub>
|
||||
let viewport: CameraInfoViewport
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('renders the main viewport through the subject camera', () => {
|
||||
viewport.setLookThrough(true)
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
|
||||
viewport.overlay.getSubjectCamera()
|
||||
)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the orbit view and keeps the gnomon hidden on exit', () => {
|
||||
viewport.setLookThrough(true)
|
||||
stub.viewHelperManager.visibleViewHelper.mockClear()
|
||||
|
||||
viewport.setLookThrough(false)
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenLastCalledWith(null)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(true)
|
||||
expect(stub.viewHelperManager.visibleViewHelper).toHaveBeenLastCalledWith(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent for repeated toggles', () => {
|
||||
viewport.setLookThrough(true)
|
||||
viewport.setLookThrough(true)
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-syncs the external camera when the camera type changes while looking through', () => {
|
||||
viewport.setLookThrough(true)
|
||||
stub.setExternalActiveCamera.mockClear()
|
||||
|
||||
const state = viewport.overlay.getState()
|
||||
viewport.applyState({ ...state, cameraType: 'orthographic' })
|
||||
|
||||
expect(stub.setExternalActiveCamera).toHaveBeenCalledWith(
|
||||
viewport.overlay.getSubjectCamera()
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves the external camera untouched on state changes outside look-through', () => {
|
||||
viewport.applyState(viewport.overlay.getState())
|
||||
|
||||
expect(stub.setExternalActiveCamera).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps handles hidden while looking through even when gizmos are toggled', () => {
|
||||
viewport.setLookThrough(true)
|
||||
viewport.setGizmosVisible(false)
|
||||
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraInfoViewport gizmo visibility', () => {
|
||||
let stub: ReturnType<typeof makeViewportStub>
|
||||
let viewport: CameraInfoViewport
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('hides orbit handles when gizmos are turned off', () => {
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(true)
|
||||
|
||||
viewport.setGizmosVisible(false)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(false)
|
||||
|
||||
viewport.setGizmosVisible(true)
|
||||
expect(viewport.orbitHandles.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('reveals the target handle in target transform mode', () => {
|
||||
viewport.setTransformGizmoMode('target')
|
||||
|
||||
expect(viewport.targetHandle.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the camera handle for camera-rotate in quaternion mode', () => {
|
||||
viewport.applyState({ ...viewport.overlay.getState(), mode: 'quaternion' })
|
||||
viewport.setTransformGizmoMode('camera-rotate')
|
||||
|
||||
expect(viewport.cameraHandle.isVisible()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CameraInfoViewport subject preview', () => {
|
||||
let stub: ReturnType<typeof makeViewportStub>
|
||||
let viewport: CameraInfoViewport
|
||||
|
||||
beforeEach(() => {
|
||||
stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
viewport = new CameraInfoViewport(document.createElement('div'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('renders the corner preview after each frame in the editing view', () => {
|
||||
stub.runPostRender()
|
||||
|
||||
expect(stub.renderer.render).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the preview for an orthographic subject camera too', () => {
|
||||
viewport.applyState({
|
||||
...viewport.overlay.getState(),
|
||||
cameraType: 'orthographic'
|
||||
})
|
||||
stub.renderer.render.mockClear()
|
||||
|
||||
stub.runPostRender()
|
||||
|
||||
expect(stub.renderer.render).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('skips the corner preview while looking through', () => {
|
||||
viewport.setLookThrough(true)
|
||||
stub.renderer.render.mockClear()
|
||||
|
||||
stub.runPostRender()
|
||||
|
||||
expect(stub.renderer.render).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
function dispatchPointer(
|
||||
target: HTMLElement,
|
||||
type: string,
|
||||
clientX: number,
|
||||
clientY: number
|
||||
): void {
|
||||
const event = new MouseEvent(type, { clientX, clientY, button: 0 })
|
||||
Object.defineProperty(event, 'pointerId', { value: 1 })
|
||||
target.dispatchEvent(event)
|
||||
}
|
||||
|
||||
describe('CameraInfoViewport look-through drag', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0)
|
||||
return 1
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rotates the subject on left-drag while looking through', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
viewport.setLookThrough(true)
|
||||
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
|
||||
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith('mode.yaw', expect.any(Number))
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('dollies the subject on wheel while looking through', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
viewport.setLookThrough(true)
|
||||
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
|
||||
|
||||
expect(onHandleDrag).toHaveBeenCalledWith(
|
||||
'mode.distance',
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('ignores the wheel when not looking through', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
stub.domElement.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
|
||||
|
||||
expect(onHandleDrag).not.toHaveBeenCalled()
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
|
||||
it('does not rotate on drag once look-through is exited', () => {
|
||||
const stub = makeViewportStub()
|
||||
createViewport3dMock.mockReturnValue(stub)
|
||||
const onHandleDrag = vi.fn()
|
||||
const viewport = new CameraInfoViewport(
|
||||
document.createElement('div'),
|
||||
undefined,
|
||||
{ onHandleDrag }
|
||||
)
|
||||
|
||||
viewport.setLookThrough(true)
|
||||
viewport.setLookThrough(false)
|
||||
dispatchPointer(stub.domElement, 'pointerdown', 100, 100)
|
||||
dispatchPointer(stub.domElement, 'pointermove', 140, 100)
|
||||
|
||||
expect(onHandleDrag).not.toHaveBeenCalled()
|
||||
|
||||
viewport.remove()
|
||||
})
|
||||
})
|
||||
699
src/extensions/core/cameraInfo/CameraInfoViewport.ts
Normal file
699
src/extensions/core/cameraInfo/CameraInfoViewport.ts
Normal file
@@ -0,0 +1,699 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { Viewport3d } from '@/extensions/core/load3d/Viewport3d'
|
||||
import { createViewport3d } from '@/extensions/core/load3d/createViewport3d'
|
||||
import type { Load3DOptions } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
import { CameraInfoOverlay } from './CameraInfoOverlay'
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import {
|
||||
CameraHandle,
|
||||
type CameraHandleMode,
|
||||
type CameraHandleTransform
|
||||
} from './handles/CameraHandle'
|
||||
import { OrbitHandles, type OrbitHandleType } from './handles/OrbitHandles'
|
||||
import { RollHandle } from './handles/RollHandle'
|
||||
import { TargetHandle } from './handles/TargetHandle'
|
||||
import { pickHandleAtPointer } from './handles/handlePicking'
|
||||
import {
|
||||
pointToDistance,
|
||||
pointToPitchAngle,
|
||||
pointToYawAngle
|
||||
} from './handles/orbitDragMath'
|
||||
import { pointToRollAngle } from './handles/rollDragMath'
|
||||
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
|
||||
import type { LookThroughResult } from './lookThroughDragMath'
|
||||
import {
|
||||
DEFAULT_CAMERA_INFO_STATE,
|
||||
type CameraInfoFieldName,
|
||||
type CameraInfoMode,
|
||||
type CameraInfoState
|
||||
} from './types'
|
||||
|
||||
const PREVIEW_WIDTH = 200
|
||||
const PREVIEW_HEIGHT = 150
|
||||
const PREVIEW_PADDING = 8
|
||||
const PREVIEW_BORDER_COLOR = 0x2a2a2a
|
||||
const LOOK_THROUGH_SENSITIVITY = 0.005
|
||||
|
||||
type DragHandleType = OrbitHandleType | 'roll'
|
||||
|
||||
export type TransformGizmoMode =
|
||||
| 'none'
|
||||
| 'target'
|
||||
| 'camera-translate'
|
||||
| 'camera-rotate'
|
||||
|
||||
const FIELD_NAME_FOR: Record<OrbitHandleType, CameraInfoFieldName> = {
|
||||
yaw: 'mode.yaw',
|
||||
pitch: 'mode.pitch',
|
||||
distance: 'mode.distance'
|
||||
}
|
||||
|
||||
export interface CameraInfoViewportOptions extends Load3DOptions {
|
||||
onHandleDrag?: (fieldName: CameraInfoFieldName, value: number) => void
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
type: DragHandleType
|
||||
pointerId: number
|
||||
}
|
||||
|
||||
export class CameraInfoViewport {
|
||||
readonly viewport: Viewport3d
|
||||
readonly overlay: CameraInfoOverlay
|
||||
readonly orbitHandles: OrbitHandles
|
||||
readonly rollHandle: RollHandle
|
||||
readonly targetHandle: TargetHandle
|
||||
readonly cameraHandle: CameraHandle
|
||||
|
||||
private readonly disposePostRender: () => void
|
||||
private readonly onHandleDrag?: CameraInfoViewportOptions['onHandleDrag']
|
||||
private readonly raycaster = new THREE.Raycaster()
|
||||
private readonly pointer = new THREE.Vector2()
|
||||
|
||||
private dragState: DragState | null = null
|
||||
private lookThroughDrag: {
|
||||
pointerId: number
|
||||
lastX: number
|
||||
lastY: number
|
||||
} | null = null
|
||||
private pendingRotation: { dx: number; dy: number } | null = null
|
||||
private pendingDolly: number | null = null
|
||||
private inputFrame: number | null = null
|
||||
private hoveredHandle: DragHandleType | null = null
|
||||
private gizmosOn = true
|
||||
private lookingThrough = false
|
||||
private transformGizmoMode: TransformGizmoMode = 'none'
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
initialState: CameraInfoState = DEFAULT_CAMERA_INFO_STATE,
|
||||
options?: CameraInfoViewportOptions
|
||||
) {
|
||||
this.onHandleDrag = options?.onHandleDrag
|
||||
this.viewport = createViewport3d(container, options)
|
||||
this.viewport.viewHelperManager.visibleViewHelper(false)
|
||||
this.overlay = new CameraInfoOverlay(initialState)
|
||||
this.viewport.setOverlay(this.overlay)
|
||||
|
||||
this.orbitHandles = new OrbitHandles()
|
||||
this.orbitHandles.attach(this.viewport.sceneManager.scene)
|
||||
this.orbitHandles.update(initialState)
|
||||
|
||||
this.rollHandle = new RollHandle()
|
||||
this.rollHandle.attach(this.viewport.sceneManager.scene)
|
||||
this.rollHandle.update(initialState)
|
||||
|
||||
this.targetHandle = new TargetHandle(
|
||||
this.viewport.cameraManager.activeCamera,
|
||||
this.viewport.domElement,
|
||||
(dragging) => {
|
||||
this.viewport.controlsManager.controls.enabled = !dragging
|
||||
},
|
||||
(target) => {
|
||||
const next: CameraInfoState = { ...this.overlay.getState(), target }
|
||||
this.applyDerivedState(next)
|
||||
this.onHandleDrag?.('target_x', target.x)
|
||||
this.onHandleDrag?.('target_y', target.y)
|
||||
this.onHandleDrag?.('target_z', target.z)
|
||||
}
|
||||
)
|
||||
this.targetHandle.attach(this.viewport.sceneManager.scene)
|
||||
this.targetHandle.setTarget(initialState.target)
|
||||
|
||||
this.cameraHandle = new CameraHandle(
|
||||
this.viewport.cameraManager.activeCamera,
|
||||
this.viewport.domElement,
|
||||
(dragging) => {
|
||||
this.viewport.controlsManager.controls.enabled = !dragging
|
||||
},
|
||||
(transform, mode) => this.handleCameraDrag(transform, mode)
|
||||
)
|
||||
this.cameraHandle.attach(this.viewport.sceneManager.scene)
|
||||
this.syncCameraHandleSubject(initialState)
|
||||
|
||||
this.attachPointerHandlers()
|
||||
|
||||
this.disposePostRender = this.viewport.addPostRenderCallback(() =>
|
||||
this.onPostRender()
|
||||
)
|
||||
}
|
||||
|
||||
applyState(state: CameraInfoState): void {
|
||||
this.overlay.applyState(state)
|
||||
this.orbitHandles.update(state)
|
||||
this.rollHandle.update(state)
|
||||
this.targetHandle.setTarget(state.target)
|
||||
this.syncCameraHandleSubject(state)
|
||||
this.refreshGizmoVisibility()
|
||||
if (this.lookingThrough) {
|
||||
this.viewport.setExternalActiveCamera(this.overlay.getSubjectCamera())
|
||||
}
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
setGizmosVisible(on: boolean): void {
|
||||
if (this.gizmosOn === on) return
|
||||
this.gizmosOn = on
|
||||
this.refreshGizmoVisibility()
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
setTransformGizmoMode(mode: TransformGizmoMode): void {
|
||||
if (this.transformGizmoMode === mode) return
|
||||
this.transformGizmoMode = mode
|
||||
this.refreshGizmoVisibility()
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
setLookThrough(on: boolean): void {
|
||||
if (this.lookingThrough === on) return
|
||||
this.lookingThrough = on
|
||||
this.lookThroughDrag = null
|
||||
this.cancelInputFrame()
|
||||
this.canvas.style.cursor = ''
|
||||
this.refreshGizmoVisibility()
|
||||
if (on) this.fitSubjectAspect()
|
||||
this.viewport.setExternalActiveCamera(
|
||||
on ? this.overlay.getSubjectCamera() : null
|
||||
)
|
||||
if (!on) this.viewport.viewHelperManager.visibleViewHelper(false)
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
this.detachPointerHandlers()
|
||||
this.cancelInputFrame()
|
||||
this.canvas.style.cursor = ''
|
||||
this.disposePostRender()
|
||||
this.orbitHandles.dispose()
|
||||
this.rollHandle.dispose()
|
||||
this.targetHandle.dispose()
|
||||
this.cameraHandle.dispose()
|
||||
this.viewport.remove()
|
||||
}
|
||||
|
||||
private refreshGizmoVisibility(): void {
|
||||
if (this.lookingThrough) {
|
||||
this.orbitHandles.setVisible(false)
|
||||
this.rollHandle.setVisible(false)
|
||||
this.targetHandle.setVisible(false)
|
||||
this.cameraHandle.setVisible(false)
|
||||
return
|
||||
}
|
||||
const mode = this.overlay.getState().mode
|
||||
this.orbitHandles.setVisible(this.gizmosOn && mode === 'orbit')
|
||||
this.rollHandle.setVisible(this.gizmosOn && rollApplies(mode))
|
||||
|
||||
const wantTarget =
|
||||
this.transformGizmoMode === 'target' && targetApplies(mode)
|
||||
this.targetHandle.setVisible(wantTarget)
|
||||
|
||||
const wantTranslate =
|
||||
this.transformGizmoMode === 'camera-translate' &&
|
||||
cameraTranslateApplies(mode)
|
||||
const wantRotate =
|
||||
this.transformGizmoMode === 'camera-rotate' && cameraRotateApplies(mode)
|
||||
const wantCamera = wantTranslate || wantRotate
|
||||
if (wantCamera)
|
||||
this.cameraHandle.setMode(wantRotate ? 'rotate' : 'translate')
|
||||
this.cameraHandle.setVisible(wantCamera)
|
||||
}
|
||||
|
||||
private syncCameraHandleSubject(state: CameraInfoState): void {
|
||||
const { position, quaternion } = computeSubjectTransform(state)
|
||||
this.cameraHandle.setSubject(
|
||||
{ x: position.x, y: position.y, z: position.z },
|
||||
{ x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }
|
||||
)
|
||||
}
|
||||
|
||||
private applyDerivedState(next: CameraInfoState): void {
|
||||
this.overlay.applyState(next)
|
||||
this.orbitHandles.update(next)
|
||||
this.rollHandle.update(next)
|
||||
this.targetHandle.setTarget(next.target)
|
||||
this.syncCameraHandleSubject(next)
|
||||
this.refreshGizmoVisibility()
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
private handleCameraDrag(
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
): void {
|
||||
const state = this.overlay.getState()
|
||||
const next = nextStateForCameraDrag(state, transform, mode)
|
||||
this.applyDerivedState(next)
|
||||
if (mode === 'translate') {
|
||||
this.onHandleDrag?.('mode.position_x', transform.position.x)
|
||||
this.onHandleDrag?.('mode.position_y', transform.position.y)
|
||||
this.onHandleDrag?.('mode.position_z', transform.position.z)
|
||||
} else {
|
||||
this.onHandleDrag?.('mode.quat_x', transform.quaternion.x)
|
||||
this.onHandleDrag?.('mode.quat_y', transform.quaternion.y)
|
||||
this.onHandleDrag?.('mode.quat_z', transform.quaternion.z)
|
||||
this.onHandleDrag?.('mode.quat_w', transform.quaternion.w)
|
||||
}
|
||||
}
|
||||
|
||||
private get canvas(): HTMLCanvasElement {
|
||||
return this.viewport.domElement
|
||||
}
|
||||
|
||||
private attachPointerHandlers(): void {
|
||||
const canvas = this.canvas
|
||||
canvas.addEventListener('pointerdown', this.onPointerDown)
|
||||
canvas.addEventListener('pointermove', this.onPointerMove)
|
||||
canvas.addEventListener('pointerup', this.onPointerUp)
|
||||
canvas.addEventListener('pointercancel', this.onPointerUp)
|
||||
canvas.addEventListener('pointerleave', this.onPointerLeave)
|
||||
canvas.addEventListener('wheel', this.onWheel, { passive: false })
|
||||
}
|
||||
|
||||
private detachPointerHandlers(): void {
|
||||
const canvas = this.canvas
|
||||
canvas.removeEventListener('pointerdown', this.onPointerDown)
|
||||
canvas.removeEventListener('pointermove', this.onPointerMove)
|
||||
canvas.removeEventListener('pointerup', this.onPointerUp)
|
||||
canvas.removeEventListener('pointercancel', this.onPointerUp)
|
||||
canvas.removeEventListener('pointerleave', this.onPointerLeave)
|
||||
canvas.removeEventListener('wheel', this.onWheel)
|
||||
}
|
||||
|
||||
private readonly onPointerLeave = (): void => {
|
||||
if (this.dragState || this.lookThroughDrag) return
|
||||
this.setHoveredHandle(null)
|
||||
}
|
||||
|
||||
private readonly onWheel = (event: WheelEvent): void => {
|
||||
if (!this.lookingThrough) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.queueDolly(event.deltaY)
|
||||
}
|
||||
|
||||
private updatePointer(event: PointerEvent): void {
|
||||
const rect = this.canvas.getBoundingClientRect()
|
||||
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||
this.pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
}
|
||||
|
||||
private pickableTargetsFor(mode: CameraInfoMode): THREE.Object3D[] {
|
||||
const targets: THREE.Object3D[] = []
|
||||
if (mode === 'orbit') {
|
||||
targets.push(...this.orbitHandles.pickableMeshes())
|
||||
}
|
||||
if (rollApplies(mode)) {
|
||||
targets.push(...this.rollHandle.pickableMeshes())
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
private pickHandle(event: PointerEvent): DragHandleType | null {
|
||||
if (!this.gizmosOn || this.lookingThrough) return null
|
||||
const targets = this.pickableTargetsFor(this.overlay.getState().mode)
|
||||
if (targets.length === 0) return null
|
||||
this.updatePointer(event)
|
||||
return pickHandleAtPointer<DragHandleType>(
|
||||
this.raycaster,
|
||||
this.pointer,
|
||||
this.viewport.cameraManager.activeCamera,
|
||||
targets,
|
||||
this.canvas
|
||||
)
|
||||
}
|
||||
|
||||
private setHoveredHandle(type: DragHandleType | null): void {
|
||||
if (this.hoveredHandle === type) return
|
||||
this.hoveredHandle = type
|
||||
this.orbitHandles.setHovered(type === 'roll' ? null : type)
|
||||
this.rollHandle.setHovered(type === 'roll')
|
||||
this.canvas.style.cursor = type ? 'grab' : ''
|
||||
this.viewport.forceRender()
|
||||
}
|
||||
|
||||
private readonly onPointerDown = (event: PointerEvent): void => {
|
||||
if (event.button !== 0) return
|
||||
|
||||
if (this.lookingThrough) {
|
||||
this.lookThroughDrag = {
|
||||
pointerId: event.pointerId,
|
||||
lastX: event.clientX,
|
||||
lastY: event.clientY
|
||||
}
|
||||
this.canvas.setPointerCapture(event.pointerId)
|
||||
this.canvas.style.cursor = 'grabbing'
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
const type = this.pickHandle(event)
|
||||
if (!type) return
|
||||
|
||||
this.setHoveredHandle(type)
|
||||
this.dragState = { type, pointerId: event.pointerId }
|
||||
this.canvas.setPointerCapture(event.pointerId)
|
||||
this.canvas.style.cursor = 'grabbing'
|
||||
this.viewport.controlsManager.controls.enabled = false
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
private readonly onPointerMove = (event: PointerEvent): void => {
|
||||
if (this.lookThroughDrag) {
|
||||
if (event.pointerId !== this.lookThroughDrag.pointerId) return
|
||||
const dx = event.clientX - this.lookThroughDrag.lastX
|
||||
const dy = event.clientY - this.lookThroughDrag.lastY
|
||||
this.lookThroughDrag.lastX = event.clientX
|
||||
this.lookThroughDrag.lastY = event.clientY
|
||||
this.queueRotation(dx, dy)
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.dragState) {
|
||||
this.setHoveredHandle(this.pickHandle(event))
|
||||
return
|
||||
}
|
||||
if (event.pointerId !== this.dragState.pointerId) return
|
||||
|
||||
this.updatePointer(event)
|
||||
this.raycaster.setFromCamera(
|
||||
this.pointer,
|
||||
this.viewport.cameraManager.activeCamera
|
||||
)
|
||||
|
||||
const state = this.overlay.getState()
|
||||
const plane =
|
||||
this.dragState.type === 'roll'
|
||||
? this.rollHandle.dragPlane(state)
|
||||
: this.orbitHandles.dragPlaneFor(this.dragState.type, state)
|
||||
const point = new THREE.Vector3()
|
||||
if (!this.raycaster.ray.intersectPlane(plane, point)) return
|
||||
|
||||
const { fieldName, value, nextState } = computeNextState(
|
||||
this.dragState.type,
|
||||
state,
|
||||
point
|
||||
)
|
||||
this.applyState(nextState)
|
||||
this.onHandleDrag?.(fieldName, value)
|
||||
}
|
||||
|
||||
private readonly onPointerUp = (event: PointerEvent): void => {
|
||||
if (this.lookThroughDrag) {
|
||||
if (event.pointerId !== this.lookThroughDrag.pointerId) return
|
||||
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||
this.canvas.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
this.lookThroughDrag = null
|
||||
this.flushInput()
|
||||
this.cancelInputFrame()
|
||||
this.canvas.style.cursor = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.dragState || event.pointerId !== this.dragState.pointerId) return
|
||||
if (this.canvas.hasPointerCapture(event.pointerId)) {
|
||||
this.canvas.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
this.dragState = null
|
||||
this.viewport.controlsManager.controls.enabled = true
|
||||
this.canvas.style.cursor = this.hoveredHandle ? 'grab' : ''
|
||||
}
|
||||
|
||||
private queueRotation(dx: number, dy: number): void {
|
||||
this.pendingRotation = {
|
||||
dx: (this.pendingRotation?.dx ?? 0) + dx,
|
||||
dy: (this.pendingRotation?.dy ?? 0) + dy
|
||||
}
|
||||
this.scheduleInputFrame()
|
||||
}
|
||||
|
||||
private queueDolly(deltaY: number): void {
|
||||
this.pendingDolly = (this.pendingDolly ?? 0) + deltaY
|
||||
this.scheduleInputFrame()
|
||||
}
|
||||
|
||||
private scheduleInputFrame(): void {
|
||||
if (this.inputFrame !== null) return
|
||||
this.inputFrame = requestAnimationFrame(() => {
|
||||
this.inputFrame = null
|
||||
this.flushInput()
|
||||
})
|
||||
}
|
||||
|
||||
private flushInput(): void {
|
||||
const rotation = this.pendingRotation
|
||||
const dolly = this.pendingDolly
|
||||
this.pendingRotation = null
|
||||
this.pendingDolly = null
|
||||
if (rotation) {
|
||||
this.applyResult(
|
||||
rotateSubjectByDrag(
|
||||
this.overlay.getState(),
|
||||
-rotation.dx * LOOK_THROUGH_SENSITIVITY,
|
||||
-rotation.dy * LOOK_THROUGH_SENSITIVITY
|
||||
)
|
||||
)
|
||||
}
|
||||
if (dolly !== null) {
|
||||
this.applyResult(dollySubjectByWheel(this.overlay.getState(), dolly))
|
||||
}
|
||||
}
|
||||
|
||||
private applyResult(result: LookThroughResult | null): void {
|
||||
if (!result) return
|
||||
this.applyState(result.nextState)
|
||||
for (const update of result.updates) {
|
||||
this.onHandleDrag?.(update.fieldName, update.value)
|
||||
}
|
||||
}
|
||||
|
||||
private cancelInputFrame(): void {
|
||||
if (this.inputFrame !== null) {
|
||||
cancelAnimationFrame(this.inputFrame)
|
||||
this.inputFrame = null
|
||||
}
|
||||
this.pendingRotation = null
|
||||
this.pendingDolly = null
|
||||
}
|
||||
|
||||
private onPostRender(): void {
|
||||
if (this.lookingThrough) {
|
||||
if (this.fitSubjectAspect()) this.viewport.forceRender()
|
||||
return
|
||||
}
|
||||
this.renderSubjectCameraPreview()
|
||||
}
|
||||
|
||||
private fitSubjectAspect(): boolean {
|
||||
const canvas = this.viewport.domElement
|
||||
const aspect = canvas.width / canvas.height
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) return false
|
||||
const cam = this.overlay.getSubjectCamera()
|
||||
if (cam instanceof THREE.PerspectiveCamera) {
|
||||
if (Math.abs(cam.aspect - aspect) < 1e-4) return false
|
||||
cam.aspect = aspect
|
||||
cam.updateProjectionMatrix()
|
||||
return true
|
||||
}
|
||||
if (cam instanceof THREE.OrthographicCamera) {
|
||||
const half = (cam.top - cam.bottom) / 2 || 1
|
||||
const left = -half * aspect
|
||||
const right = half * aspect
|
||||
if (
|
||||
Math.abs(cam.left - left) < 1e-4 &&
|
||||
Math.abs(cam.right - right) < 1e-4
|
||||
)
|
||||
return false
|
||||
cam.left = left
|
||||
cam.right = right
|
||||
cam.updateProjectionMatrix()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private renderSubjectCameraPreview(): void {
|
||||
const renderer = this.viewport.renderer
|
||||
const canvas = this.viewport.domElement
|
||||
const canvasWidth = canvas.width
|
||||
const canvasHeight = canvas.height
|
||||
if (
|
||||
canvasWidth < PREVIEW_WIDTH + PREVIEW_PADDING * 2 ||
|
||||
canvasHeight < PREVIEW_HEIGHT + PREVIEW_PADDING * 2
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const cam = this.overlay.getSubjectCamera()
|
||||
const aspect = PREVIEW_WIDTH / PREVIEW_HEIGHT
|
||||
let savedAspect: number | undefined
|
||||
let savedOrtho:
|
||||
| {
|
||||
left: number
|
||||
right: number
|
||||
top: number
|
||||
bottom: number
|
||||
}
|
||||
| undefined
|
||||
|
||||
if (cam instanceof THREE.PerspectiveCamera) {
|
||||
savedAspect = cam.aspect
|
||||
cam.aspect = aspect
|
||||
cam.updateProjectionMatrix()
|
||||
} else if (cam instanceof THREE.OrthographicCamera) {
|
||||
savedOrtho = {
|
||||
left: cam.left,
|
||||
right: cam.right,
|
||||
top: cam.top,
|
||||
bottom: cam.bottom
|
||||
}
|
||||
const half = (cam.top - cam.bottom) / 2 || 1
|
||||
cam.left = -half * aspect
|
||||
cam.right = half * aspect
|
||||
cam.top = half
|
||||
cam.bottom = -half
|
||||
cam.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.overlay.setHelperVisible(false)
|
||||
const handlesWereVisible = this.orbitHandles.isVisible()
|
||||
const rollWasVisible = this.rollHandle.isVisible()
|
||||
const targetWasVisible = this.targetHandle.isVisible()
|
||||
const cameraWasVisible = this.cameraHandle.isVisible()
|
||||
this.orbitHandles.setVisible(false)
|
||||
this.rollHandle.setVisible(false)
|
||||
this.targetHandle.setVisible(false)
|
||||
this.cameraHandle.setVisible(false)
|
||||
|
||||
const x = canvasWidth - PREVIEW_WIDTH - PREVIEW_PADDING
|
||||
const y = PREVIEW_PADDING
|
||||
|
||||
renderer.setViewport(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
|
||||
renderer.setScissor(x - 1, y - 1, PREVIEW_WIDTH + 2, PREVIEW_HEIGHT + 2)
|
||||
renderer.setScissorTest(true)
|
||||
renderer.setClearColor(PREVIEW_BORDER_COLOR)
|
||||
renderer.clear()
|
||||
|
||||
renderer.setViewport(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
|
||||
renderer.setScissor(x, y, PREVIEW_WIDTH, PREVIEW_HEIGHT)
|
||||
renderer.setClearColor(0x0a0a0a)
|
||||
renderer.clear()
|
||||
renderer.render(this.viewport.sceneManager.scene, cam)
|
||||
|
||||
this.overlay.setHelperVisible(true)
|
||||
if (handlesWereVisible) this.orbitHandles.setVisible(true)
|
||||
if (rollWasVisible) this.rollHandle.setVisible(true)
|
||||
if (targetWasVisible) this.targetHandle.setVisible(true)
|
||||
if (cameraWasVisible) this.cameraHandle.setVisible(true)
|
||||
|
||||
if (savedAspect !== undefined && cam instanceof THREE.PerspectiveCamera) {
|
||||
cam.aspect = savedAspect
|
||||
cam.updateProjectionMatrix()
|
||||
} else if (savedOrtho && cam instanceof THREE.OrthographicCamera) {
|
||||
cam.left = savedOrtho.left
|
||||
cam.right = savedOrtho.right
|
||||
cam.top = savedOrtho.top
|
||||
cam.bottom = savedOrtho.bottom
|
||||
cam.updateProjectionMatrix()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OrbitDragResult {
|
||||
fieldName: CameraInfoFieldName
|
||||
value: number
|
||||
nextState: CameraInfoState
|
||||
}
|
||||
|
||||
function computeNextState(
|
||||
type: DragHandleType,
|
||||
state: CameraInfoState,
|
||||
point: THREE.Vector3
|
||||
): OrbitDragResult {
|
||||
if (type === 'roll') {
|
||||
const cameraPos = computeSubjectTransform(state).position
|
||||
const value = pointToRollAngle(
|
||||
{ x: point.x, y: point.y, z: point.z },
|
||||
state.target,
|
||||
{ x: cameraPos.x, y: cameraPos.y, z: cameraPos.z }
|
||||
)
|
||||
return {
|
||||
fieldName: 'roll',
|
||||
value,
|
||||
nextState: { ...state, roll: value }
|
||||
}
|
||||
}
|
||||
const fieldName = FIELD_NAME_FOR[type]
|
||||
if (type === 'yaw') {
|
||||
const value = pointToYawAngle(point, state.target)
|
||||
return {
|
||||
fieldName,
|
||||
value,
|
||||
nextState: { ...state, orbit: { ...state.orbit, yaw: value } }
|
||||
}
|
||||
}
|
||||
if (type === 'pitch') {
|
||||
const value = pointToPitchAngle(point, state.target, state.orbit.yaw)
|
||||
return {
|
||||
fieldName,
|
||||
value,
|
||||
nextState: { ...state, orbit: { ...state.orbit, pitch: value } }
|
||||
}
|
||||
}
|
||||
const value = pointToDistance(
|
||||
point,
|
||||
state.target,
|
||||
state.orbit.yaw,
|
||||
state.orbit.pitch
|
||||
)
|
||||
return {
|
||||
fieldName,
|
||||
value,
|
||||
nextState: { ...state, orbit: { ...state.orbit, distance: value } }
|
||||
}
|
||||
}
|
||||
|
||||
function targetApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'orbit' || mode === 'look_at'
|
||||
}
|
||||
|
||||
function cameraTranslateApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'look_at' || mode === 'quaternion'
|
||||
}
|
||||
|
||||
function cameraRotateApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'quaternion'
|
||||
}
|
||||
|
||||
function rollApplies(mode: CameraInfoMode): boolean {
|
||||
return mode === 'orbit' || mode === 'look_at'
|
||||
}
|
||||
|
||||
function nextStateForCameraDrag(
|
||||
state: CameraInfoState,
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
): CameraInfoState {
|
||||
const { position, quaternion } = transform
|
||||
if (mode === 'translate') {
|
||||
if (state.mode === 'look_at') {
|
||||
return { ...state, lookAt: { position: { ...position } } }
|
||||
}
|
||||
if (state.mode === 'quaternion') {
|
||||
return {
|
||||
...state,
|
||||
quaternion: { ...state.quaternion, position: { ...position } }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
if (state.mode === 'quaternion') {
|
||||
return {
|
||||
...state,
|
||||
quaternion: { ...state.quaternion, quat: { ...quaternion } }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
145
src/extensions/core/cameraInfo/cameraTransform.test.ts
Normal file
145
src/extensions/core/cameraInfo/cameraTransform.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeSubjectTransform } from './cameraTransform'
|
||||
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
|
||||
|
||||
function withMode(
|
||||
mode: CameraInfoState['mode'],
|
||||
overrides: Partial<CameraInfoState> = {}
|
||||
): CameraInfoState {
|
||||
return { ...DEFAULT_CAMERA_INFO_STATE, mode, ...overrides }
|
||||
}
|
||||
|
||||
describe('computeSubjectTransform', () => {
|
||||
describe('orbit mode', () => {
|
||||
it('positions the camera at target + distance along yaw/pitch', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(0)
|
||||
expect(position.y).toBeCloseTo(0)
|
||||
expect(position.z).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('honors yaw=90deg (camera goes to +X)', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 90, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(5)
|
||||
expect(position.y).toBeCloseTo(0)
|
||||
expect(position.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('honors pitch=90deg (camera goes straight up)', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 0, pitch: 90, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(0)
|
||||
expect(position.y).toBeCloseTo(5)
|
||||
expect(position.z).toBeCloseTo(0, 5)
|
||||
})
|
||||
|
||||
it('offsets the orbit by target', () => {
|
||||
const state = withMode('orbit', {
|
||||
target: { x: 10, y: 20, z: 30 },
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.x).toBeCloseTo(10)
|
||||
expect(position.y).toBeCloseTo(20)
|
||||
expect(position.z).toBeCloseTo(35)
|
||||
})
|
||||
})
|
||||
|
||||
describe('look_at mode', () => {
|
||||
it('uses explicit position', () => {
|
||||
const state = withMode('look_at', {
|
||||
lookAt: { position: { x: 1, y: 2, z: 3 } }
|
||||
})
|
||||
|
||||
const { position } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.toArray()).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('quaternion mode', () => {
|
||||
it('uses explicit position and quaternion (target ignored)', () => {
|
||||
const state = withMode('quaternion', {
|
||||
target: { x: 999, y: 999, z: 999 },
|
||||
quaternion: {
|
||||
position: { x: 7, y: 8, z: 9 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
const { position, quaternion } = computeSubjectTransform(state)
|
||||
|
||||
expect(position.toArray()).toEqual([7, 8, 9])
|
||||
expect(quaternion.x).toBeCloseTo(0)
|
||||
expect(quaternion.y).toBeCloseTo(0)
|
||||
expect(quaternion.z).toBeCloseTo(0)
|
||||
expect(quaternion.w).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('normalizes a non-unit quaternion', () => {
|
||||
const state = withMode('quaternion', {
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quat: { x: 2, y: 0, z: 0, w: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const { quaternion } = computeSubjectTransform(state)
|
||||
|
||||
expect(quaternion.length()).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('falls back to identity when given a zero quaternion', () => {
|
||||
const state = withMode('quaternion', {
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
const { quaternion } = computeSubjectTransform(state)
|
||||
|
||||
expect(quaternion.x).toBe(0)
|
||||
expect(quaternion.y).toBe(0)
|
||||
expect(quaternion.z).toBe(0)
|
||||
expect(quaternion.w).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roll', () => {
|
||||
it('rotates around the view axis without moving the camera position', () => {
|
||||
const base = withMode('look_at', {
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
roll: 0
|
||||
})
|
||||
const rolled = { ...base, roll: 45 }
|
||||
|
||||
const a = computeSubjectTransform(base)
|
||||
const b = computeSubjectTransform(rolled)
|
||||
|
||||
expect(a.position.toArray()).toEqual(b.position.toArray())
|
||||
expect(a.quaternion.equals(b.quaternion)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
89
src/extensions/core/cameraInfo/cameraTransform.ts
Normal file
89
src/extensions/core/cameraInfo/cameraTransform.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { CameraInfoState } from './types'
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
export interface SubjectCameraTransform {
|
||||
position: THREE.Vector3
|
||||
quaternion: THREE.Quaternion
|
||||
}
|
||||
|
||||
function orbitPosition(
|
||||
target: THREE.Vector3Like,
|
||||
yawDeg: number,
|
||||
pitchDeg: number,
|
||||
distance: number
|
||||
): THREE.Vector3 {
|
||||
const y = yawDeg * DEG2RAD
|
||||
const p = pitchDeg * DEG2RAD
|
||||
const cp = Math.cos(p)
|
||||
return new THREE.Vector3(
|
||||
target.x + distance * cp * Math.sin(y),
|
||||
target.y + distance * Math.sin(p),
|
||||
target.z + distance * cp * Math.cos(y)
|
||||
)
|
||||
}
|
||||
|
||||
function lookAtQuaternion(
|
||||
position: THREE.Vector3,
|
||||
target: THREE.Vector3Like,
|
||||
rollDeg: number
|
||||
): THREE.Quaternion {
|
||||
const targetVec = new THREE.Vector3(target.x, target.y, target.z)
|
||||
const m = new THREE.Matrix4().lookAt(
|
||||
position,
|
||||
targetVec,
|
||||
new THREE.Vector3(0, 1, 0)
|
||||
)
|
||||
const q = new THREE.Quaternion().setFromRotationMatrix(m)
|
||||
if (rollDeg !== 0) {
|
||||
const rollQ = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 0, 1),
|
||||
rollDeg * DEG2RAD
|
||||
)
|
||||
q.multiply(rollQ)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
export function normalizeQuaternion(q: THREE.Quaternion): THREE.Quaternion {
|
||||
if (q.lengthSq() === 0) q.set(0, 0, 0, 1)
|
||||
else q.normalize()
|
||||
return q
|
||||
}
|
||||
|
||||
export function computeSubjectTransform(
|
||||
state: CameraInfoState
|
||||
): SubjectCameraTransform {
|
||||
if (state.mode === 'quaternion') {
|
||||
const p = state.quaternion.position
|
||||
const q = state.quaternion.quat
|
||||
const quaternion = normalizeQuaternion(
|
||||
new THREE.Quaternion(q.x, q.y, q.z, q.w)
|
||||
)
|
||||
return {
|
||||
position: new THREE.Vector3(p.x, p.y, p.z),
|
||||
quaternion
|
||||
}
|
||||
}
|
||||
|
||||
const position =
|
||||
state.mode === 'orbit'
|
||||
? orbitPosition(
|
||||
state.target,
|
||||
state.orbit.yaw,
|
||||
state.orbit.pitch,
|
||||
state.orbit.distance
|
||||
)
|
||||
: new THREE.Vector3(
|
||||
state.lookAt.position.x,
|
||||
state.lookAt.position.y,
|
||||
state.lookAt.position.z
|
||||
)
|
||||
|
||||
return {
|
||||
position,
|
||||
quaternion: lookAtQuaternion(position, state.target, state.roll)
|
||||
}
|
||||
}
|
||||
103
src/extensions/core/cameraInfo/handles/CameraHandle.test.ts
Normal file
103
src/extensions/core/cameraInfo/handles/CameraHandle.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
CameraHandle,
|
||||
type CameraHandleMode,
|
||||
type CameraHandleTransform
|
||||
} from './CameraHandle'
|
||||
|
||||
describe('CameraHandle', () => {
|
||||
let scene: THREE.Scene
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let dom: HTMLElement
|
||||
let onDragging: (dragging: boolean) => void
|
||||
let onChange: (
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
) => void
|
||||
let onChangeMock: ReturnType<typeof vi.fn>
|
||||
let handle: CameraHandle
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
dom = document.createElement('div')
|
||||
onDragging = vi.fn() as unknown as (dragging: boolean) => void
|
||||
onChangeMock = vi.fn()
|
||||
onChange = onChangeMock as unknown as (
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
) => void
|
||||
handle = new CameraHandle(camera, dom, onDragging, onChange)
|
||||
handle.attach(scene)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('adds a proxy + a helper to the scene on attach', () => {
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
|
||||
).toBeDefined()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('starts hidden with controls disabled', () => {
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to translate mode', () => {
|
||||
expect(handle.getMode()).toBe('translate')
|
||||
})
|
||||
|
||||
it('setMode switches translate <-> rotate', () => {
|
||||
handle.setMode('rotate')
|
||||
expect(handle.getMode()).toBe('rotate')
|
||||
|
||||
handle.setMode('translate')
|
||||
expect(handle.getMode()).toBe('translate')
|
||||
})
|
||||
|
||||
it('setSubject moves the proxy to the given pose without firing onChange', () => {
|
||||
handle.setSubject({ x: 1, y: 2, z: 3 }, { x: 0, y: 0, z: 0, w: 1 })
|
||||
|
||||
const proxy = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoCameraProxy'
|
||||
)!
|
||||
expect(proxy.position.x).toBe(1)
|
||||
expect(proxy.position.y).toBe(2)
|
||||
expect(proxy.position.z).toBe(3)
|
||||
expect(onChangeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setSubject is a no-op when the pose already matches', () => {
|
||||
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
|
||||
onChangeMock.mockClear()
|
||||
|
||||
handle.setSubject({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: 0, w: 1 })
|
||||
|
||||
expect(onChangeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setVisible toggles helper.visible and controls.enabled together', () => {
|
||||
handle.setVisible(true)
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
|
||||
handle.setVisible(false)
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('dispose removes proxy and helper from the scene', () => {
|
||||
handle.dispose()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraProxy')
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoCameraHandle')
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
149
src/extensions/core/cameraInfo/handles/CameraHandle.ts
Normal file
149
src/extensions/core/cameraInfo/handles/CameraHandle.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as THREE from 'three'
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
|
||||
import type { DraggingChangeListener } from './types'
|
||||
|
||||
export type CameraHandleMode = 'translate' | 'rotate'
|
||||
|
||||
export interface CameraHandleTransform {
|
||||
position: THREE.Vector3Like
|
||||
quaternion: { x: number; y: number; z: number; w: number }
|
||||
}
|
||||
|
||||
type ChangeListener = (
|
||||
transform: CameraHandleTransform,
|
||||
mode: CameraHandleMode
|
||||
) => void
|
||||
|
||||
export class CameraHandle {
|
||||
private readonly proxy: THREE.Object3D
|
||||
private readonly controls: TransformControls
|
||||
private readonly helper: THREE.Object3D
|
||||
private mode: CameraHandleMode = 'translate'
|
||||
private suppressEcho = false
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
camera: THREE.Camera,
|
||||
domElement: HTMLElement,
|
||||
private readonly onDraggingChange: DraggingChangeListener,
|
||||
private readonly onChange: ChangeListener
|
||||
) {
|
||||
this.proxy = new THREE.Object3D()
|
||||
this.proxy.name = 'CameraInfoCameraProxy'
|
||||
|
||||
this.controls = new TransformControls(camera, domElement)
|
||||
this.controls.setMode(this.mode)
|
||||
this.controls.setSize(0.8)
|
||||
this.controls.setSpace(spaceFor(this.mode))
|
||||
this.controls.attach(this.proxy)
|
||||
this.helper = this.controls.getHelper()
|
||||
this.helper.name = 'CameraInfoCameraHandle'
|
||||
this.helper.visible = false
|
||||
this.controls.enabled = false
|
||||
|
||||
this.controls.addEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.addEventListener('objectChange', this.onObjectChange)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.proxy)
|
||||
scene.add(this.helper)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.proxy)
|
||||
this.scene.remove(this.helper)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.helper.visible = visible
|
||||
this.controls.enabled = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.helper.visible
|
||||
}
|
||||
|
||||
setMode(mode: CameraHandleMode): void {
|
||||
if (this.mode === mode) return
|
||||
this.mode = mode
|
||||
this.controls.setMode(mode)
|
||||
this.controls.setSpace(spaceFor(mode))
|
||||
}
|
||||
|
||||
getMode(): CameraHandleMode {
|
||||
return this.mode
|
||||
}
|
||||
|
||||
setSubject(
|
||||
position: THREE.Vector3Like,
|
||||
quaternion: { x: number; y: number; z: number; w: number }
|
||||
): void {
|
||||
const samePosition =
|
||||
this.proxy.position.x === position.x &&
|
||||
this.proxy.position.y === position.y &&
|
||||
this.proxy.position.z === position.z
|
||||
const sameQuaternion =
|
||||
this.proxy.quaternion.x === quaternion.x &&
|
||||
this.proxy.quaternion.y === quaternion.y &&
|
||||
this.proxy.quaternion.z === quaternion.z &&
|
||||
this.proxy.quaternion.w === quaternion.w
|
||||
if (samePosition && sameQuaternion) return
|
||||
this.suppressEcho = true
|
||||
try {
|
||||
this.proxy.position.set(position.x, position.y, position.z)
|
||||
this.proxy.quaternion.set(
|
||||
quaternion.x,
|
||||
quaternion.y,
|
||||
quaternion.z,
|
||||
quaternion.w
|
||||
)
|
||||
this.proxy.updateMatrixWorld(true)
|
||||
} finally {
|
||||
this.suppressEcho = false
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.controls.removeEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.removeEventListener('objectChange', this.onObjectChange)
|
||||
this.controls.detach()
|
||||
this.detach()
|
||||
this.controls.dispose()
|
||||
}
|
||||
|
||||
private readonly onDragging = (event: { value: unknown }): void => {
|
||||
this.onDraggingChange(event.value === true)
|
||||
}
|
||||
|
||||
private readonly onObjectChange = (): void => {
|
||||
if (this.suppressEcho) return
|
||||
this.onChange(
|
||||
{
|
||||
position: {
|
||||
x: this.proxy.position.x,
|
||||
y: this.proxy.position.y,
|
||||
z: this.proxy.position.z
|
||||
},
|
||||
quaternion: {
|
||||
x: this.proxy.quaternion.x,
|
||||
y: this.proxy.quaternion.y,
|
||||
z: this.proxy.quaternion.z,
|
||||
w: this.proxy.quaternion.w
|
||||
}
|
||||
},
|
||||
this.mode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function spaceFor(mode: CameraHandleMode): 'world' | 'local' {
|
||||
return mode === 'translate' ? 'world' : 'local'
|
||||
}
|
||||
108
src/extensions/core/cameraInfo/handles/OrbitHandles.test.ts
Normal file
108
src/extensions/core/cameraInfo/handles/OrbitHandles.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
|
||||
import { OrbitHandles } from './OrbitHandles'
|
||||
|
||||
describe('OrbitHandles', () => {
|
||||
let scene: THREE.Scene
|
||||
let handles: OrbitHandles
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
handles = new OrbitHandles()
|
||||
handles.attach(scene)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handles.dispose()
|
||||
})
|
||||
|
||||
it('adds a single root group to the scene on attach', () => {
|
||||
const root = scene.children.find((c) => c.name === 'CameraInfoOrbitHandles')
|
||||
expect(root).toBeDefined()
|
||||
})
|
||||
|
||||
it('exposes three pickable handle meshes tagged with handleType', () => {
|
||||
const meshes = handles.pickableMeshes()
|
||||
expect(meshes).toHaveLength(3)
|
||||
const types = meshes.map((m) => m.userData.handleType).sort()
|
||||
expect(types).toEqual(['distance', 'pitch', 'yaw'])
|
||||
})
|
||||
|
||||
it('positions the yaw handle on the +Z side of the ring at yaw=0', () => {
|
||||
handles.update({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
|
||||
const yawHandle = handles
|
||||
.pickableMeshes()
|
||||
.find((m) => m.userData.handleType === 'yaw')!
|
||||
expect(yawHandle.position.x).toBeCloseTo(0)
|
||||
expect(yawHandle.position.z).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('places the distance handle at the camera (distance along yaw direction)', () => {
|
||||
handles.update({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
orbit: { yaw: 90, pitch: 0, distance: 7 }
|
||||
})
|
||||
|
||||
const distanceHandle = handles
|
||||
.pickableMeshes()
|
||||
.find((m) => m.userData.handleType === 'distance')!
|
||||
const world = new THREE.Vector3()
|
||||
distanceHandle.getWorldPosition(world)
|
||||
expect(world.x).toBeCloseTo(7)
|
||||
expect(world.y).toBeCloseTo(0)
|
||||
expect(world.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('hides itself in non-orbit modes', () => {
|
||||
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
|
||||
|
||||
const root = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoOrbitHandles'
|
||||
)!
|
||||
expect(root.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('setVisible forces visibility independent of mode', () => {
|
||||
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
|
||||
handles.setVisible(false)
|
||||
|
||||
const root = scene.children.find(
|
||||
(c) => c.name === 'CameraInfoOrbitHandles'
|
||||
)!
|
||||
expect(root.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('isVisible reflects the root group, not individual handle meshes', () => {
|
||||
handles.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
|
||||
|
||||
expect(handles.isVisible()).toBe(false)
|
||||
expect(handles.pickableMeshes()[0].visible).toBe(true)
|
||||
})
|
||||
|
||||
it('yaw drag plane is horizontal through target', () => {
|
||||
const plane = handles.dragPlaneFor('yaw', {
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
target: { x: 0, y: 3, z: 0 }
|
||||
})
|
||||
expect(plane.normal.y).toBeCloseTo(1)
|
||||
expect(plane.constant).toBeCloseTo(-3)
|
||||
})
|
||||
|
||||
it('pitch drag plane is vertical and orthogonal to yaw direction', () => {
|
||||
const plane = handles.dragPlaneFor('pitch', {
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
orbit: { yaw: 0, pitch: 0, distance: 5 }
|
||||
})
|
||||
expect(Math.abs(plane.normal.x)).toBeCloseTo(1)
|
||||
expect(plane.normal.y).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
258
src/extensions/core/cameraInfo/handles/OrbitHandles.ts
Normal file
258
src/extensions/core/cameraInfo/handles/OrbitHandles.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import type { CameraInfoState } from '../types'
|
||||
|
||||
const RING_RADIUS = 1.5
|
||||
const HANDLE_RADIUS = 0.08
|
||||
const GLOW_RADIUS = 0.12
|
||||
const TUBE_RADIUS = 0.025
|
||||
const ARC_PITCH_LIMIT = 85
|
||||
const ARC_SEGMENTS = 32
|
||||
|
||||
const YAW_COLOR = 0x00d4ff
|
||||
const PITCH_COLOR = 0xff66ff
|
||||
const DISTANCE_COLOR = 0xffcc00
|
||||
|
||||
const BASE_GLOW_OPACITY = 0.25
|
||||
const HOVER_GLOW_OPACITY = 0.6
|
||||
const HOVER_SCALE = 1.35
|
||||
|
||||
export type OrbitHandleType = 'yaw' | 'pitch' | 'distance'
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
function buildArcCurve(): THREE.CatmullRomCurve3 {
|
||||
const points: THREE.Vector3[] = []
|
||||
for (let deg = -ARC_PITCH_LIMIT; deg <= ARC_PITCH_LIMIT; deg += 5) {
|
||||
const r = deg * DEG2RAD
|
||||
points.push(
|
||||
new THREE.Vector3(0, RING_RADIUS * Math.sin(r), RING_RADIUS * Math.cos(r))
|
||||
)
|
||||
}
|
||||
return new THREE.CatmullRomCurve3(points)
|
||||
}
|
||||
|
||||
function makeHandle(color: number, type: OrbitHandleType): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
emissive: color,
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.3,
|
||||
metalness: 0.2
|
||||
})
|
||||
)
|
||||
mesh.userData.handleType = type
|
||||
return mesh
|
||||
}
|
||||
|
||||
function makeGlow(color: number): THREE.Mesh {
|
||||
return new THREE.Mesh(
|
||||
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: BASE_GLOW_OPACITY,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export class OrbitHandles {
|
||||
private readonly root = new THREE.Group()
|
||||
private readonly yawGroup = new THREE.Group()
|
||||
|
||||
private readonly yawRing: THREE.Mesh
|
||||
private readonly pitchArc: THREE.Mesh
|
||||
private readonly distanceLine: THREE.Line
|
||||
private readonly distanceLineGeometry: THREE.BufferGeometry
|
||||
|
||||
private readonly yawHandle: THREE.Mesh
|
||||
private readonly pitchHandle: THREE.Mesh
|
||||
private readonly distanceHandle: THREE.Mesh
|
||||
|
||||
private readonly yawHandleGlow: THREE.Mesh
|
||||
private readonly pitchHandleGlow: THREE.Mesh
|
||||
private readonly distanceHandleGlow: THREE.Mesh
|
||||
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor() {
|
||||
this.root.name = 'CameraInfoOrbitHandles'
|
||||
this.root.add(this.yawGroup)
|
||||
|
||||
this.yawRing = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 96),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: YAW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.yawRing.rotation.x = Math.PI / 2
|
||||
this.root.add(this.yawRing)
|
||||
|
||||
this.pitchArc = new THREE.Mesh(
|
||||
new THREE.TubeGeometry(
|
||||
buildArcCurve(),
|
||||
ARC_SEGMENTS,
|
||||
TUBE_RADIUS,
|
||||
8,
|
||||
false
|
||||
),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: PITCH_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.yawGroup.add(this.pitchArc)
|
||||
|
||||
this.distanceLineGeometry = new THREE.BufferGeometry()
|
||||
this.distanceLineGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.Float32BufferAttribute([0, 0, 0, 0, 0, 1], 3)
|
||||
)
|
||||
this.distanceLine = new THREE.Line(
|
||||
this.distanceLineGeometry,
|
||||
new THREE.LineBasicMaterial({
|
||||
color: DISTANCE_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.yawGroup.add(this.distanceLine)
|
||||
|
||||
this.yawHandle = makeHandle(YAW_COLOR, 'yaw')
|
||||
this.yawHandleGlow = makeGlow(YAW_COLOR)
|
||||
this.yawHandle.add(this.yawHandleGlow)
|
||||
this.root.add(this.yawHandle)
|
||||
|
||||
this.pitchHandle = makeHandle(PITCH_COLOR, 'pitch')
|
||||
this.pitchHandleGlow = makeGlow(PITCH_COLOR)
|
||||
this.pitchHandle.add(this.pitchHandleGlow)
|
||||
this.yawGroup.add(this.pitchHandle)
|
||||
|
||||
this.distanceHandle = makeHandle(DISTANCE_COLOR, 'distance')
|
||||
this.distanceHandleGlow = makeGlow(DISTANCE_COLOR)
|
||||
this.distanceHandle.add(this.distanceHandleGlow)
|
||||
this.yawGroup.add(this.distanceHandle)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.root)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.root)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.detach()
|
||||
const disposables: { dispose: () => void }[] = [
|
||||
this.yawRing.geometry,
|
||||
this.yawRing.material as THREE.Material,
|
||||
this.pitchArc.geometry,
|
||||
this.pitchArc.material as THREE.Material,
|
||||
this.distanceLineGeometry,
|
||||
this.distanceLine.material as THREE.Material,
|
||||
this.yawHandle.geometry,
|
||||
this.yawHandle.material as THREE.Material,
|
||||
this.pitchHandle.geometry,
|
||||
this.pitchHandle.material as THREE.Material,
|
||||
this.distanceHandle.geometry,
|
||||
this.distanceHandle.material as THREE.Material,
|
||||
this.yawHandleGlow.geometry,
|
||||
this.yawHandleGlow.material as THREE.Material,
|
||||
this.pitchHandleGlow.geometry,
|
||||
this.pitchHandleGlow.material as THREE.Material,
|
||||
this.distanceHandleGlow.geometry,
|
||||
this.distanceHandleGlow.material as THREE.Material
|
||||
]
|
||||
for (const d of disposables) d.dispose()
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.root.visible = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.root.visible
|
||||
}
|
||||
|
||||
update(state: CameraInfoState): void {
|
||||
const visible = state.mode === 'orbit'
|
||||
this.root.visible = visible
|
||||
if (!visible) return
|
||||
|
||||
this.root.position.set(state.target.x, state.target.y, state.target.z)
|
||||
|
||||
const yawRad = state.orbit.yaw * DEG2RAD
|
||||
const pitchRad = state.orbit.pitch * DEG2RAD
|
||||
const cosP = Math.cos(pitchRad)
|
||||
const sinP = Math.sin(pitchRad)
|
||||
|
||||
this.yawGroup.rotation.y = yawRad
|
||||
|
||||
this.yawHandle.position.set(
|
||||
RING_RADIUS * Math.sin(yawRad),
|
||||
0,
|
||||
RING_RADIUS * Math.cos(yawRad)
|
||||
)
|
||||
|
||||
this.pitchHandle.position.set(0, RING_RADIUS * sinP, RING_RADIUS * cosP)
|
||||
|
||||
const endY = state.orbit.distance * sinP
|
||||
const endZ = state.orbit.distance * cosP
|
||||
this.distanceHandle.position.set(0, endY, endZ)
|
||||
|
||||
const positions = this.distanceLineGeometry.attributes
|
||||
.position as THREE.BufferAttribute
|
||||
positions.setXYZ(1, 0, endY, endZ)
|
||||
positions.needsUpdate = true
|
||||
}
|
||||
|
||||
pickableMeshes(): THREE.Object3D[] {
|
||||
return [this.yawHandle, this.pitchHandle, this.distanceHandle]
|
||||
}
|
||||
|
||||
setHovered(type: OrbitHandleType | null): void {
|
||||
const entries: [THREE.Mesh, THREE.Mesh, OrbitHandleType][] = [
|
||||
[this.yawHandle, this.yawHandleGlow, 'yaw'],
|
||||
[this.pitchHandle, this.pitchHandleGlow, 'pitch'],
|
||||
[this.distanceHandle, this.distanceHandleGlow, 'distance']
|
||||
]
|
||||
for (const [handle, glow, handleType] of entries) {
|
||||
const active = handleType === type
|
||||
handle.scale.setScalar(active ? HOVER_SCALE : 1)
|
||||
;(glow.material as THREE.MeshBasicMaterial).opacity = active
|
||||
? HOVER_GLOW_OPACITY
|
||||
: BASE_GLOW_OPACITY
|
||||
}
|
||||
}
|
||||
|
||||
dragPlaneFor(type: OrbitHandleType, state: CameraInfoState): THREE.Plane {
|
||||
if (type === 'yaw') {
|
||||
return new THREE.Plane(new THREE.Vector3(0, 1, 0), -state.target.y)
|
||||
}
|
||||
const yawRad = state.orbit.yaw * DEG2RAD
|
||||
const normal = new THREE.Vector3(
|
||||
-Math.cos(yawRad),
|
||||
0,
|
||||
Math.sin(yawRad)
|
||||
).normalize()
|
||||
const targetVec = vec(state.target)
|
||||
return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, targetVec)
|
||||
}
|
||||
}
|
||||
|
||||
const vec = (v: THREE.Vector3Like): THREE.Vector3 =>
|
||||
new THREE.Vector3(v.x, v.y, v.z)
|
||||
78
src/extensions/core/cameraInfo/handles/RollHandle.test.ts
Normal file
78
src/extensions/core/cameraInfo/handles/RollHandle.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from '../types'
|
||||
import { RollHandle } from './RollHandle'
|
||||
|
||||
describe('RollHandle', () => {
|
||||
let scene: THREE.Scene
|
||||
let handle: RollHandle
|
||||
|
||||
beforeEach(() => {
|
||||
scene = new THREE.Scene()
|
||||
handle = new RollHandle()
|
||||
handle.attach(scene)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
handle.dispose()
|
||||
})
|
||||
|
||||
it('attaches a single root group to the scene', () => {
|
||||
expect(
|
||||
scene.children.find((c) => c.name === 'CameraInfoRollHandle')
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
it('exposes one pickable mesh tagged handleType="roll"', () => {
|
||||
const meshes = handle.pickableMeshes()
|
||||
expect(meshes).toHaveLength(1)
|
||||
expect(meshes[0].userData.handleType).toBe('roll')
|
||||
})
|
||||
|
||||
it('hides itself in quaternion mode (rotate gizmo owns roll there)', () => {
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'quaternion' })
|
||||
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('is visible in orbit and look_at modes', () => {
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'look_at' })
|
||||
expect(handle.isVisible()).toBe(true)
|
||||
})
|
||||
|
||||
it('setVisible forces visibility', () => {
|
||||
handle.update({ ...DEFAULT_CAMERA_INFO_STATE, mode: 'orbit' })
|
||||
handle.setVisible(false)
|
||||
|
||||
expect(handle.isVisible()).toBe(false)
|
||||
})
|
||||
|
||||
it('positions itself at the target', () => {
|
||||
handle.update({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'orbit',
|
||||
target: { x: 1, y: 2, z: 3 }
|
||||
})
|
||||
|
||||
const root = scene.children.find((c) => c.name === 'CameraInfoRollHandle')!
|
||||
expect(root.position.x).toBeCloseTo(1)
|
||||
expect(root.position.y).toBeCloseTo(2)
|
||||
expect(root.position.z).toBeCloseTo(3)
|
||||
})
|
||||
|
||||
it('drag plane normal points along the camera→target backward direction', () => {
|
||||
const plane = handle.dragPlane({
|
||||
...DEFAULT_CAMERA_INFO_STATE,
|
||||
mode: 'look_at',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } }
|
||||
})
|
||||
expect(plane.normal.x).toBeCloseTo(0)
|
||||
expect(plane.normal.y).toBeCloseTo(0)
|
||||
expect(Math.abs(plane.normal.z)).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
160
src/extensions/core/cameraInfo/handles/RollHandle.ts
Normal file
160
src/extensions/core/cameraInfo/handles/RollHandle.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { computeSubjectTransform } from '../cameraTransform'
|
||||
import type { CameraInfoState } from '../types'
|
||||
import { rollBasis } from './rollDragMath'
|
||||
|
||||
const RING_RADIUS = 0.9
|
||||
const HANDLE_RADIUS = 0.08
|
||||
const GLOW_RADIUS = 0.12
|
||||
const TUBE_RADIUS = 0.025
|
||||
|
||||
const ROLL_COLOR = 0xff8800
|
||||
const BASE_GLOW_OPACITY = 0.25
|
||||
const HOVER_GLOW_OPACITY = 0.6
|
||||
const HOVER_SCALE = 1.35
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
function makeHandle(): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(HANDLE_RADIUS, 32, 32),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: ROLL_COLOR,
|
||||
emissive: ROLL_COLOR,
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.3,
|
||||
metalness: 0.2
|
||||
})
|
||||
)
|
||||
mesh.userData.handleType = 'roll'
|
||||
return mesh
|
||||
}
|
||||
|
||||
export class RollHandle {
|
||||
private readonly root = new THREE.Group()
|
||||
private readonly ring: THREE.Mesh
|
||||
private readonly handle: THREE.Mesh
|
||||
private readonly handleGlow: THREE.Mesh
|
||||
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor() {
|
||||
this.root.name = 'CameraInfoRollHandle'
|
||||
|
||||
this.ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(RING_RADIUS, TUBE_RADIUS, 16, 80),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: ROLL_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.55
|
||||
})
|
||||
)
|
||||
this.root.add(this.ring)
|
||||
|
||||
this.handle = makeHandle()
|
||||
this.handleGlow = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(GLOW_RADIUS, 16, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: ROLL_COLOR,
|
||||
transparent: true,
|
||||
opacity: BASE_GLOW_OPACITY,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
)
|
||||
this.handle.add(this.handleGlow)
|
||||
this.root.add(this.handle)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.root)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.root)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.detach()
|
||||
const disposables: { dispose: () => void }[] = [
|
||||
this.ring.geometry,
|
||||
this.ring.material as THREE.Material,
|
||||
this.handle.geometry,
|
||||
this.handle.material as THREE.Material,
|
||||
this.handleGlow.geometry,
|
||||
this.handleGlow.material as THREE.Material
|
||||
]
|
||||
for (const d of disposables) d.dispose()
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.root.visible = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.root.visible
|
||||
}
|
||||
|
||||
update(state: CameraInfoState): void {
|
||||
if (state.mode === 'quaternion') {
|
||||
this.root.visible = false
|
||||
return
|
||||
}
|
||||
this.root.visible = true
|
||||
|
||||
const { position: cameraPos } = computeSubjectTransform(state)
|
||||
const cameraVec: THREE.Vector3Like = {
|
||||
x: cameraPos.x,
|
||||
y: cameraPos.y,
|
||||
z: cameraPos.z
|
||||
}
|
||||
const { up, right, backward } = rollBasis(state.target, cameraVec)
|
||||
|
||||
this.root.position.set(state.target.x, state.target.y, state.target.z)
|
||||
const orient = new THREE.Quaternion().setFromRotationMatrix(
|
||||
new THREE.Matrix4().makeBasis(right, up, backward)
|
||||
)
|
||||
this.root.quaternion.copy(orient)
|
||||
|
||||
const theta = state.roll * DEG2RAD
|
||||
this.handle.position.set(
|
||||
RING_RADIUS * Math.sin(theta),
|
||||
RING_RADIUS * Math.cos(theta),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
pickableMeshes(): THREE.Object3D[] {
|
||||
return [this.handle]
|
||||
}
|
||||
|
||||
setHovered(hovered: boolean): void {
|
||||
this.handle.scale.setScalar(hovered ? HOVER_SCALE : 1)
|
||||
;(this.handleGlow.material as THREE.MeshBasicMaterial).opacity = hovered
|
||||
? HOVER_GLOW_OPACITY
|
||||
: BASE_GLOW_OPACITY
|
||||
}
|
||||
|
||||
dragPlane(state: CameraInfoState): THREE.Plane {
|
||||
const { position: cameraPos } = computeSubjectTransform(state)
|
||||
const cameraVec: THREE.Vector3Like = {
|
||||
x: cameraPos.x,
|
||||
y: cameraPos.y,
|
||||
z: cameraPos.z
|
||||
}
|
||||
const { backward } = rollBasis(state.target, cameraVec)
|
||||
const tgt = new THREE.Vector3(
|
||||
state.target.x,
|
||||
state.target.y,
|
||||
state.target.z
|
||||
)
|
||||
return new THREE.Plane().setFromNormalAndCoplanarPoint(backward, tgt)
|
||||
}
|
||||
}
|
||||
99
src/extensions/core/cameraInfo/handles/TargetHandle.ts
Normal file
99
src/extensions/core/cameraInfo/handles/TargetHandle.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as THREE from 'three'
|
||||
import { TransformControls } from 'three/examples/jsm/controls/TransformControls'
|
||||
|
||||
import type { DraggingChangeListener } from './types'
|
||||
|
||||
type ChangeListener = (target: THREE.Vector3Like) => void
|
||||
|
||||
export class TargetHandle {
|
||||
private readonly proxy: THREE.Object3D
|
||||
private readonly controls: TransformControls
|
||||
private readonly helper: THREE.Object3D
|
||||
private suppressEcho = false
|
||||
private scene: THREE.Scene | null = null
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
camera: THREE.Camera,
|
||||
domElement: HTMLElement,
|
||||
private readonly onDraggingChange: DraggingChangeListener,
|
||||
private readonly onChange: ChangeListener
|
||||
) {
|
||||
this.proxy = new THREE.Object3D()
|
||||
this.proxy.name = 'CameraInfoTargetProxy'
|
||||
|
||||
this.controls = new TransformControls(camera, domElement)
|
||||
this.controls.setMode('translate')
|
||||
this.controls.setSize(0.8)
|
||||
this.controls.attach(this.proxy)
|
||||
this.helper = this.controls.getHelper()
|
||||
this.helper.name = 'CameraInfoTargetHandle'
|
||||
this.helper.visible = false
|
||||
this.controls.enabled = false
|
||||
|
||||
this.controls.addEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.addEventListener('objectChange', this.onObjectChange)
|
||||
}
|
||||
|
||||
attach(scene: THREE.Scene): void {
|
||||
this.scene = scene
|
||||
scene.add(this.proxy)
|
||||
scene.add(this.helper)
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
if (!this.scene) return
|
||||
this.scene.remove(this.proxy)
|
||||
this.scene.remove(this.helper)
|
||||
this.scene = null
|
||||
}
|
||||
|
||||
setVisible(visible: boolean): void {
|
||||
this.helper.visible = visible
|
||||
this.controls.enabled = visible
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return this.helper.visible
|
||||
}
|
||||
|
||||
setTarget(target: THREE.Vector3Like): void {
|
||||
if (
|
||||
this.proxy.position.x === target.x &&
|
||||
this.proxy.position.y === target.y &&
|
||||
this.proxy.position.z === target.z
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.suppressEcho = true
|
||||
try {
|
||||
this.proxy.position.set(target.x, target.y, target.z)
|
||||
this.proxy.updateMatrixWorld(true)
|
||||
} finally {
|
||||
this.suppressEcho = false
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.controls.removeEventListener('dragging-changed', this.onDragging)
|
||||
this.controls.removeEventListener('objectChange', this.onObjectChange)
|
||||
this.controls.detach()
|
||||
this.detach()
|
||||
this.controls.dispose()
|
||||
}
|
||||
|
||||
private readonly onDragging = (event: { value: unknown }): void => {
|
||||
this.onDraggingChange(event.value === true)
|
||||
}
|
||||
|
||||
private readonly onObjectChange = (): void => {
|
||||
if (this.suppressEcho) return
|
||||
this.onChange({
|
||||
x: this.proxy.position.x,
|
||||
y: this.proxy.position.y,
|
||||
z: this.proxy.position.z
|
||||
})
|
||||
}
|
||||
}
|
||||
102
src/extensions/core/cameraInfo/handles/handlePicking.test.ts
Normal file
102
src/extensions/core/cameraInfo/handles/handlePicking.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as THREE from 'three'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { pickHandleAtPointer } from './handlePicking'
|
||||
|
||||
const CANVAS = { clientWidth: 400, clientHeight: 400 }
|
||||
|
||||
function makeHandle(handleType: string, position: THREE.Vector3): THREE.Mesh {
|
||||
const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8))
|
||||
mesh.userData.handleType = handleType
|
||||
mesh.position.copy(position)
|
||||
mesh.updateMatrixWorld(true)
|
||||
return mesh
|
||||
}
|
||||
|
||||
describe('pickHandleAtPointer', () => {
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let raycaster: THREE.Raycaster
|
||||
|
||||
beforeEach(() => {
|
||||
camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100)
|
||||
camera.position.set(0, 0, 10)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateMatrixWorld(true)
|
||||
camera.updateProjectionMatrix()
|
||||
raycaster = new THREE.Raycaster()
|
||||
})
|
||||
|
||||
it('returns the handle type on a direct raycast hit', () => {
|
||||
const handle = makeHandle('yaw', new THREE.Vector3(0, 0, 0))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0, 0),
|
||||
camera,
|
||||
[handle],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBe('yaw')
|
||||
})
|
||||
|
||||
it('picks a handle within the screen-space tolerance on a near miss', () => {
|
||||
const handle = makeHandle('pitch', new THREE.Vector3(0, 0, 0))
|
||||
// ~10px off centre: outside the 0.08-radius sphere but inside tolerance.
|
||||
const nearMissNdc = new THREE.Vector2(10 / (CANVAS.clientWidth / 2), 0)
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
nearMissNdc,
|
||||
camera,
|
||||
[handle],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBe('pitch')
|
||||
})
|
||||
|
||||
it('returns null when the pointer is far from every handle', () => {
|
||||
const handle = makeHandle('distance', new THREE.Vector3(0, 0, 0))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0.5, 0.5),
|
||||
camera,
|
||||
[handle],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the nearest handle when several are within tolerance', () => {
|
||||
const near = makeHandle('yaw', new THREE.Vector3(0.05, 0, 5))
|
||||
const far = makeHandle('pitch', new THREE.Vector3(-0.3, 0, 5))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0.02, 0),
|
||||
camera,
|
||||
[far, near],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBe('yaw')
|
||||
})
|
||||
|
||||
it('ignores handles behind the camera', () => {
|
||||
const behind = makeHandle('yaw', new THREE.Vector3(0, 0, 20))
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0, 0),
|
||||
camera,
|
||||
[behind],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null with no targets', () => {
|
||||
const picked = pickHandleAtPointer(
|
||||
raycaster,
|
||||
new THREE.Vector2(0, 0),
|
||||
camera,
|
||||
[],
|
||||
CANVAS
|
||||
)
|
||||
expect(picked).toBeNull()
|
||||
})
|
||||
})
|
||||
51
src/extensions/core/cameraInfo/handles/handlePicking.ts
Normal file
51
src/extensions/core/cameraInfo/handles/handlePicking.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
const PICK_PIXEL_RADIUS = 14
|
||||
|
||||
interface CanvasSize {
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
function toScreenPx(
|
||||
ndcX: number,
|
||||
ndcY: number,
|
||||
canvas: CanvasSize
|
||||
): THREE.Vector2 {
|
||||
return new THREE.Vector2(
|
||||
(ndcX + 1) * 0.5 * canvas.clientWidth,
|
||||
(1 - (ndcY + 1) * 0.5) * canvas.clientHeight
|
||||
)
|
||||
}
|
||||
|
||||
export function pickHandleAtPointer<T extends string>(
|
||||
raycaster: THREE.Raycaster,
|
||||
pointerNdc: THREE.Vector2,
|
||||
camera: THREE.Camera,
|
||||
targets: THREE.Object3D[],
|
||||
canvas: CanvasSize
|
||||
): T | null {
|
||||
if (targets.length === 0) return null
|
||||
|
||||
raycaster.setFromCamera(pointerNdc, camera)
|
||||
const hits = raycaster.intersectObjects(targets, false)
|
||||
if (hits.length > 0) return hits[0].object.userData.handleType as T
|
||||
|
||||
const pointerPx = toScreenPx(pointerNdc.x, pointerNdc.y, canvas)
|
||||
const world = new THREE.Vector3()
|
||||
let best: T | null = null
|
||||
let bestDistance = PICK_PIXEL_RADIUS
|
||||
for (const target of targets) {
|
||||
target.getWorldPosition(world)
|
||||
const view = world.clone().applyMatrix4(camera.matrixWorldInverse)
|
||||
if (view.z >= 0) continue // behind the camera
|
||||
const ndc = world.project(camera)
|
||||
const px = toScreenPx(ndc.x, ndc.y, canvas)
|
||||
const distance = px.distanceTo(pointerPx)
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
best = target.userData.handleType as T
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
107
src/extensions/core/cameraInfo/handles/orbitDragMath.test.ts
Normal file
107
src/extensions/core/cameraInfo/handles/orbitDragMath.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAX_DISTANCE,
|
||||
MAX_PITCH,
|
||||
MIN_DISTANCE,
|
||||
MIN_PITCH,
|
||||
pointToDistance,
|
||||
pointToPitchAngle,
|
||||
pointToYawAngle
|
||||
} from './orbitDragMath'
|
||||
|
||||
const TARGET = { x: 0, y: 0, z: 0 }
|
||||
const SHIFTED_TARGET = { x: 2, y: 1, z: -3 }
|
||||
|
||||
describe('pointToYawAngle', () => {
|
||||
it('returns 0 when the point sits on +Z relative to the target', () => {
|
||||
expect(pointToYawAngle({ x: 0, y: 0, z: 5 }, TARGET)).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns 90 when the point sits on +X (rotation around Y)', () => {
|
||||
expect(pointToYawAngle({ x: 5, y: 0, z: 0 }, TARGET)).toBeCloseTo(90)
|
||||
})
|
||||
|
||||
it('returns -90 when the point sits on -X', () => {
|
||||
expect(pointToYawAngle({ x: -5, y: 0, z: 0 }, TARGET)).toBeCloseTo(-90)
|
||||
})
|
||||
|
||||
it('returns 180 (or -180) when the point sits on -Z', () => {
|
||||
expect(
|
||||
Math.abs(pointToYawAngle({ x: 0, y: 0, z: -5 }, TARGET))
|
||||
).toBeCloseTo(180)
|
||||
})
|
||||
|
||||
it('is invariant to the y component of the point and target', () => {
|
||||
expect(
|
||||
pointToYawAngle({ x: 3, y: 99, z: 4 }, { x: 0, y: -7, z: 0 })
|
||||
).toBeCloseTo(pointToYawAngle({ x: 3, y: 0, z: 4 }, TARGET))
|
||||
})
|
||||
|
||||
it('works against a non-origin target', () => {
|
||||
expect(
|
||||
pointToYawAngle(
|
||||
{ x: SHIFTED_TARGET.x, y: 0, z: SHIFTED_TARGET.z + 5 },
|
||||
SHIFTED_TARGET
|
||||
)
|
||||
).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointToPitchAngle', () => {
|
||||
it('returns 0 when the point lies in the horizontal plane', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: 0, z: 5 }, TARGET, 0)).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns +45 for a point lifted to (0, h, h) at yaw=0', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: 5, z: 5 }, TARGET, 0)).toBeCloseTo(45)
|
||||
})
|
||||
|
||||
it('returns -30 for a downward-pointing intersection at yaw=0', () => {
|
||||
const h = 5
|
||||
const v = h * Math.tan((30 * Math.PI) / 180)
|
||||
expect(pointToPitchAngle({ x: 0, y: -v, z: h }, TARGET, 0)).toBeCloseTo(-30)
|
||||
})
|
||||
|
||||
it('respects the current yaw direction when projecting horizontally', () => {
|
||||
expect(pointToPitchAngle({ x: 5, y: 5, z: 0 }, TARGET, 90)).toBeCloseTo(45)
|
||||
})
|
||||
|
||||
it('clamps to MAX_PITCH past the upper pole', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: 100, z: 0.001 }, TARGET, 0)).toBe(
|
||||
MAX_PITCH
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps to MIN_PITCH past the lower pole', () => {
|
||||
expect(pointToPitchAngle({ x: 0, y: -100, z: 0.001 }, TARGET, 0)).toBe(
|
||||
MIN_PITCH
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointToDistance', () => {
|
||||
it('returns the radial projection at yaw=0, pitch=0', () => {
|
||||
expect(pointToDistance({ x: 0, y: 0, z: 7 }, TARGET, 0, 0)).toBeCloseTo(7)
|
||||
})
|
||||
|
||||
it('returns the radial projection along the current yaw direction', () => {
|
||||
expect(pointToDistance({ x: 4, y: 0, z: 0 }, TARGET, 90, 0)).toBeCloseTo(4)
|
||||
})
|
||||
|
||||
it('ignores off-axis components of the point', () => {
|
||||
expect(pointToDistance({ x: 99, y: 0, z: 5 }, TARGET, 0, 0)).toBeCloseTo(5)
|
||||
})
|
||||
|
||||
it('clamps to MIN_DISTANCE when the projection goes behind the target', () => {
|
||||
expect(pointToDistance({ x: 0, y: 0, z: -10 }, TARGET, 0, 0)).toBe(
|
||||
MIN_DISTANCE
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps to MAX_DISTANCE when the projection overshoots', () => {
|
||||
expect(pointToDistance({ x: 0, y: 0, z: 99999 }, TARGET, 0, 0)).toBe(
|
||||
MAX_DISTANCE
|
||||
)
|
||||
})
|
||||
})
|
||||
50
src/extensions/core/cameraInfo/handles/orbitDragMath.ts
Normal file
50
src/extensions/core/cameraInfo/handles/orbitDragMath.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
|
||||
import type { Vector3Like } from 'three'
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
const DEG2RAD = Math.PI / 180
|
||||
|
||||
export const MIN_DISTANCE = 0.5
|
||||
export const MAX_DISTANCE = 100
|
||||
export const MIN_PITCH = -89
|
||||
export const MAX_PITCH = 89
|
||||
|
||||
export function pointToYawAngle(
|
||||
point: Vector3Like,
|
||||
target: Vector3Like
|
||||
): number {
|
||||
return Math.atan2(point.x - target.x, point.z - target.z) * RAD2DEG
|
||||
}
|
||||
|
||||
export function pointToPitchAngle(
|
||||
point: Vector3Like,
|
||||
target: Vector3Like,
|
||||
yawDeg: number
|
||||
): number {
|
||||
const y = yawDeg * DEG2RAD
|
||||
const dx = point.x - target.x
|
||||
const dy = point.y - target.y
|
||||
const dz = point.z - target.z
|
||||
const horizontal = dx * Math.sin(y) + dz * Math.cos(y)
|
||||
const raw = Math.atan2(dy, horizontal) * RAD2DEG
|
||||
return clamp(raw, MIN_PITCH, MAX_PITCH)
|
||||
}
|
||||
|
||||
export function pointToDistance(
|
||||
point: Vector3Like,
|
||||
target: Vector3Like,
|
||||
yawDeg: number,
|
||||
pitchDeg: number
|
||||
): number {
|
||||
const y = yawDeg * DEG2RAD
|
||||
const p = pitchDeg * DEG2RAD
|
||||
const dirX = Math.cos(p) * Math.sin(y)
|
||||
const dirY = Math.sin(p)
|
||||
const dirZ = Math.cos(p) * Math.cos(y)
|
||||
const dx = point.x - target.x
|
||||
const dy = point.y - target.y
|
||||
const dz = point.z - target.z
|
||||
const projection = dx * dirX + dy * dirY + dz * dirZ
|
||||
return clamp(projection, MIN_DISTANCE, MAX_DISTANCE)
|
||||
}
|
||||
64
src/extensions/core/cameraInfo/handles/rollDragMath.test.ts
Normal file
64
src/extensions/core/cameraInfo/handles/rollDragMath.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { pointToRollAngle, rollBasis } from './rollDragMath'
|
||||
|
||||
const TARGET = { x: 0, y: 0, z: 0 }
|
||||
|
||||
describe('rollBasis', () => {
|
||||
it('returns world (+X right, +Y up) when the camera looks along -Z', () => {
|
||||
const basis = rollBasis(TARGET, { x: 0, y: 0, z: 5 })
|
||||
expect(basis.up.x).toBeCloseTo(0)
|
||||
expect(basis.up.y).toBeCloseTo(1)
|
||||
expect(basis.up.z).toBeCloseTo(0)
|
||||
expect(basis.right.x).toBeCloseTo(1)
|
||||
expect(basis.right.y).toBeCloseTo(0)
|
||||
expect(basis.right.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('falls back to a +X right when the camera looks straight down', () => {
|
||||
const basis = rollBasis(TARGET, { x: 0, y: 5, z: 0 })
|
||||
expect(basis.right.x).toBeCloseTo(1)
|
||||
})
|
||||
|
||||
it('orients (up, right) tangent to the camera ray for an oblique view', () => {
|
||||
const basis = rollBasis(TARGET, { x: 3, y: 4, z: 0 })
|
||||
|
||||
expect(basis.right.z).toBeCloseTo(-1)
|
||||
expect(basis.up.dot(basis.right)).toBeCloseTo(0)
|
||||
expect(basis.up.dot(basis.backward)).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointToRollAngle', () => {
|
||||
const CAMERA = { x: 0, y: 0, z: 5 }
|
||||
|
||||
it('returns 0 when the point sits in the +up direction', () => {
|
||||
expect(pointToRollAngle({ x: 0, y: 1, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
it('returns +90 when the point sits in the +right direction', () => {
|
||||
expect(pointToRollAngle({ x: 1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
|
||||
90
|
||||
)
|
||||
})
|
||||
|
||||
it('returns -90 when the point sits in the -right direction', () => {
|
||||
expect(pointToRollAngle({ x: -1, y: 0, z: 0 }, TARGET, CAMERA)).toBeCloseTo(
|
||||
-90
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 180 (or -180) when the point sits in the -up direction', () => {
|
||||
expect(
|
||||
Math.abs(pointToRollAngle({ x: 0, y: -1, z: 0 }, TARGET, CAMERA))
|
||||
).toBeCloseTo(180)
|
||||
})
|
||||
|
||||
it('is invariant to the off-plane component of the point', () => {
|
||||
const base = pointToRollAngle({ x: 1, y: 1, z: 0 }, TARGET, CAMERA)
|
||||
const offPlane = pointToRollAngle({ x: 1, y: 1, z: 7 }, TARGET, CAMERA)
|
||||
expect(offPlane).toBeCloseTo(base)
|
||||
})
|
||||
})
|
||||
40
src/extensions/core/cameraInfo/handles/rollDragMath.ts
Normal file
40
src/extensions/core/cameraInfo/handles/rollDragMath.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as THREE from 'three'
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
|
||||
export interface RollBasis {
|
||||
up: THREE.Vector3
|
||||
right: THREE.Vector3
|
||||
backward: THREE.Vector3
|
||||
}
|
||||
|
||||
export function rollBasis(
|
||||
target: THREE.Vector3Like,
|
||||
cameraPos: THREE.Vector3Like
|
||||
): RollBasis {
|
||||
const backward = new THREE.Vector3(
|
||||
cameraPos.x - target.x,
|
||||
cameraPos.y - target.y,
|
||||
cameraPos.z - target.z
|
||||
).normalize()
|
||||
const worldUp = new THREE.Vector3(0, 1, 0)
|
||||
const right = new THREE.Vector3().crossVectors(worldUp, backward)
|
||||
if (right.lengthSq() < 1e-8) right.set(1, 0, 0)
|
||||
else right.normalize()
|
||||
const up = new THREE.Vector3().crossVectors(backward, right).normalize()
|
||||
return { up, right, backward }
|
||||
}
|
||||
|
||||
export function pointToRollAngle(
|
||||
point: THREE.Vector3Like,
|
||||
target: THREE.Vector3Like,
|
||||
cameraPos: THREE.Vector3Like
|
||||
): number {
|
||||
const { up, right } = rollBasis(target, cameraPos)
|
||||
const rel = new THREE.Vector3(
|
||||
point.x - target.x,
|
||||
point.y - target.y,
|
||||
point.z - target.z
|
||||
)
|
||||
return Math.atan2(rel.dot(right), rel.dot(up)) * RAD2DEG
|
||||
}
|
||||
1
src/extensions/core/cameraInfo/handles/types.ts
Normal file
1
src/extensions/core/cameraInfo/handles/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type DraggingChangeListener = (dragging: boolean) => void
|
||||
202
src/extensions/core/cameraInfo/lookThroughDragMath.test.ts
Normal file
202
src/extensions/core/cameraInfo/lookThroughDragMath.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import * as THREE from 'three'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { dollySubjectByWheel, rotateSubjectByDrag } from './lookThroughDragMath'
|
||||
import { DEFAULT_CAMERA_INFO_STATE, type CameraInfoState } from './types'
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
|
||||
function stateWith(overrides: Partial<CameraInfoState>): CameraInfoState {
|
||||
return { ...structuredClone(DEFAULT_CAMERA_INFO_STATE), ...overrides }
|
||||
}
|
||||
|
||||
describe('rotateSubjectByDrag - orbit', () => {
|
||||
it('adds the drag deltas to yaw and pitch in degrees', () => {
|
||||
const state = stateWith({ mode: 'orbit' })
|
||||
const result = rotateSubjectByDrag(state, 0.1, 0.05)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.nextState.orbit.yaw).toBeCloseTo(
|
||||
state.orbit.yaw + 0.1 * RAD2DEG
|
||||
)
|
||||
expect(result!.nextState.orbit.pitch).toBeCloseTo(
|
||||
state.orbit.pitch + 0.05 * RAD2DEG
|
||||
)
|
||||
expect(result!.updates.map((u) => u.fieldName)).toEqual([
|
||||
'mode.yaw',
|
||||
'mode.pitch'
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps pitch to the pole limit', () => {
|
||||
const state = stateWith({
|
||||
mode: 'orbit',
|
||||
orbit: { yaw: 0, pitch: 80, distance: 4 }
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0, 1)
|
||||
|
||||
expect(result!.nextState.orbit.pitch).toBe(89)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateSubjectByDrag - look_at', () => {
|
||||
it('rotates the target around a fixed camera position, preserving distance', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0.2, 0)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const pos = new THREE.Vector3(0, 0, 5)
|
||||
const before = new THREE.Vector3(0, 0, 0).distanceTo(pos)
|
||||
const nextTarget = result!.nextState.target
|
||||
const after = new THREE.Vector3(
|
||||
nextTarget.x,
|
||||
nextTarget.y,
|
||||
nextTarget.z
|
||||
).distanceTo(pos)
|
||||
|
||||
expect(after).toBeCloseTo(before)
|
||||
expect(nextTarget.x).not.toBeCloseTo(0)
|
||||
expect(result!.updates.map((u) => u.fieldName)).toEqual([
|
||||
'target_x',
|
||||
'target_y',
|
||||
'target_z'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when the camera sits on the target', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 1, y: 1, z: 1 } },
|
||||
target: { x: 1, y: 1, z: 1 }
|
||||
})
|
||||
|
||||
expect(rotateSubjectByDrag(state, 0.1, 0.1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rotateSubjectByDrag - quaternion', () => {
|
||||
it('rotates the orientation and keeps a unit quaternion', () => {
|
||||
const state = stateWith({
|
||||
mode: 'quaternion',
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 5 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0.2, 0.1)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const q = result!.nextState.quaternion.quat
|
||||
const length = Math.hypot(q.x, q.y, q.z, q.w)
|
||||
expect(length).toBeCloseTo(1)
|
||||
expect(q).not.toEqual({ x: 0, y: 0, z: 0, w: 1 })
|
||||
expect(result!.nextState.quaternion.position).toEqual({ x: 0, y: 0, z: 5 })
|
||||
expect(result!.updates.map((u) => u.fieldName)).toEqual([
|
||||
'mode.quat_x',
|
||||
'mode.quat_y',
|
||||
'mode.quat_z',
|
||||
'mode.quat_w'
|
||||
])
|
||||
})
|
||||
|
||||
it('treats a zero-length quaternion as identity', () => {
|
||||
const state = stateWith({
|
||||
mode: 'quaternion',
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 0 }
|
||||
}
|
||||
})
|
||||
const result = rotateSubjectByDrag(state, 0.1, 0)
|
||||
const q = result!.nextState.quaternion.quat
|
||||
|
||||
expect(Math.hypot(q.x, q.y, q.z, q.w)).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dollySubjectByWheel - perspective', () => {
|
||||
it('orbit: scrolling up shortens distance, scrolling down lengthens it', () => {
|
||||
const state = stateWith({ mode: 'orbit', cameraType: 'perspective' })
|
||||
|
||||
const closer = rotateResultDistance(dollySubjectByWheel(state, -100))
|
||||
const farther = rotateResultDistance(dollySubjectByWheel(state, 100))
|
||||
|
||||
expect(closer).toBeLessThan(state.orbit.distance)
|
||||
expect(farther).toBeGreaterThan(state.orbit.distance)
|
||||
})
|
||||
|
||||
it('orbit: clamps distance to the same bounds as the handle drag', () => {
|
||||
const state = stateWith({
|
||||
mode: 'orbit',
|
||||
orbit: { yaw: 0, pitch: 0, distance: 4 }
|
||||
})
|
||||
|
||||
expect(dollySubjectByWheel(state, -100000)!.nextState.orbit.distance).toBe(
|
||||
0.5
|
||||
)
|
||||
expect(dollySubjectByWheel(state, 100000)!.nextState.orbit.distance).toBe(
|
||||
100
|
||||
)
|
||||
})
|
||||
|
||||
it('look_at: moves the position toward the target, preserving direction', () => {
|
||||
const state = stateWith({
|
||||
mode: 'look_at',
|
||||
lookAt: { position: { x: 0, y: 0, z: 5 } },
|
||||
target: { x: 0, y: 0, z: 0 }
|
||||
})
|
||||
const result = dollySubjectByWheel(state, -100)
|
||||
const pos = result!.nextState.lookAt.position
|
||||
|
||||
expect(pos.z).toBeGreaterThan(0)
|
||||
expect(pos.z).toBeLessThan(5)
|
||||
expect(pos.x).toBeCloseTo(0)
|
||||
expect(pos.y).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('quaternion: moves the position along the camera forward axis', () => {
|
||||
const state = stateWith({
|
||||
mode: 'quaternion',
|
||||
quaternion: {
|
||||
position: { x: 0, y: 0, z: 5 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
})
|
||||
const result = dollySubjectByWheel(state, -100)
|
||||
const pos = result!.nextState.quaternion.position
|
||||
|
||||
expect(pos.z).toBeCloseTo(4)
|
||||
expect(pos.x).toBeCloseTo(0)
|
||||
expect(pos.y).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dollySubjectByWheel - orthographic', () => {
|
||||
it('changes zoom rather than position, regardless of mode', () => {
|
||||
const state = stateWith({ mode: 'orbit', cameraType: 'orthographic' })
|
||||
const zoomedIn = dollySubjectByWheel(state, -100)
|
||||
const zoomedOut = dollySubjectByWheel(state, 100)
|
||||
|
||||
expect(zoomedIn!.updates[0].fieldName).toBe('zoom')
|
||||
expect(zoomedIn!.nextState.zoom).toBeGreaterThan(state.zoom)
|
||||
expect(zoomedOut!.nextState.zoom).toBeLessThan(state.zoom)
|
||||
expect(zoomedIn!.nextState.orbit.distance).toBe(state.orbit.distance)
|
||||
})
|
||||
|
||||
it('clamps zoom within bounds', () => {
|
||||
const state = stateWith({ cameraType: 'orthographic', zoom: 1 })
|
||||
|
||||
expect(dollySubjectByWheel(state, -100000)!.nextState.zoom).toBe(100)
|
||||
expect(dollySubjectByWheel(state, 100000)!.nextState.zoom).toBe(0.05)
|
||||
})
|
||||
})
|
||||
|
||||
function rotateResultDistance(
|
||||
result: ReturnType<typeof dollySubjectByWheel>
|
||||
): number {
|
||||
return result!.nextState.orbit.distance
|
||||
}
|
||||
271
src/extensions/core/cameraInfo/lookThroughDragMath.ts
Normal file
271
src/extensions/core/cameraInfo/lookThroughDragMath.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { clamp } from 'es-toolkit'
|
||||
import * as THREE from 'three'
|
||||
|
||||
import { normalizeQuaternion } from './cameraTransform'
|
||||
import {
|
||||
MAX_DISTANCE,
|
||||
MAX_PITCH,
|
||||
MIN_DISTANCE,
|
||||
MIN_PITCH
|
||||
} from './handles/orbitDragMath'
|
||||
import type { CameraInfoFieldName, CameraInfoState } from './types'
|
||||
|
||||
const DEG2RAD = Math.PI / 180
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
const ELEVATION_LIMIT = MAX_PITCH * DEG2RAD
|
||||
const DOLLY_EXP_SENSITIVITY = 0.0015
|
||||
const FREE_DOLLY_UNIT = 0.01
|
||||
const MIN_ZOOM = 0.05
|
||||
const MAX_ZOOM = 100
|
||||
|
||||
interface FieldUpdate {
|
||||
fieldName: CameraInfoFieldName
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface LookThroughResult {
|
||||
nextState: CameraInfoState
|
||||
updates: FieldUpdate[]
|
||||
}
|
||||
|
||||
function directionToSpherical(dir: THREE.Vector3): {
|
||||
azimuth: number
|
||||
elevation: number
|
||||
} {
|
||||
return {
|
||||
azimuth: Math.atan2(dir.x, dir.z),
|
||||
elevation: Math.asin(clamp(dir.y, -1, 1))
|
||||
}
|
||||
}
|
||||
|
||||
function sphericalToDirection(
|
||||
azimuth: number,
|
||||
elevation: number
|
||||
): THREE.Vector3 {
|
||||
const ce = Math.cos(elevation)
|
||||
return new THREE.Vector3(
|
||||
ce * Math.sin(azimuth),
|
||||
Math.sin(elevation),
|
||||
ce * Math.cos(azimuth)
|
||||
)
|
||||
}
|
||||
|
||||
function rotateOrbit(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult {
|
||||
const yaw = state.orbit.yaw + yawDelta * RAD2DEG
|
||||
const pitch = clamp(
|
||||
state.orbit.pitch + pitchDelta * RAD2DEG,
|
||||
MIN_PITCH,
|
||||
MAX_PITCH
|
||||
)
|
||||
return {
|
||||
nextState: { ...state, orbit: { ...state.orbit, yaw, pitch } },
|
||||
updates: [
|
||||
{ fieldName: 'mode.yaw', value: yaw },
|
||||
{ fieldName: 'mode.pitch', value: pitch }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function rotateLookAt(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult | null {
|
||||
const position = new THREE.Vector3(
|
||||
state.lookAt.position.x,
|
||||
state.lookAt.position.y,
|
||||
state.lookAt.position.z
|
||||
)
|
||||
const target = new THREE.Vector3(
|
||||
state.target.x,
|
||||
state.target.y,
|
||||
state.target.z
|
||||
)
|
||||
const offset = target.clone().sub(position)
|
||||
const distance = offset.length()
|
||||
if (distance < 1e-6) return null
|
||||
|
||||
const { azimuth, elevation } = directionToSpherical(
|
||||
offset.clone().normalize()
|
||||
)
|
||||
const nextAzimuth = azimuth + yawDelta
|
||||
const nextElevation = clamp(
|
||||
elevation + pitchDelta,
|
||||
-ELEVATION_LIMIT,
|
||||
ELEVATION_LIMIT
|
||||
)
|
||||
const dir = sphericalToDirection(nextAzimuth, nextElevation)
|
||||
const nextTarget: THREE.Vector3Like = {
|
||||
x: position.x + dir.x * distance,
|
||||
y: position.y + dir.y * distance,
|
||||
z: position.z + dir.z * distance
|
||||
}
|
||||
return {
|
||||
nextState: { ...state, target: nextTarget },
|
||||
updates: [
|
||||
{ fieldName: 'target_x', value: nextTarget.x },
|
||||
{ fieldName: 'target_y', value: nextTarget.y },
|
||||
{ fieldName: 'target_z', value: nextTarget.z }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function rotateQuaternion(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult {
|
||||
const q = normalizeQuaternion(
|
||||
new THREE.Quaternion(
|
||||
state.quaternion.quat.x,
|
||||
state.quaternion.quat.y,
|
||||
state.quaternion.quat.z,
|
||||
state.quaternion.quat.w
|
||||
)
|
||||
)
|
||||
|
||||
const yawQ = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(0, 1, 0),
|
||||
yawDelta
|
||||
)
|
||||
const pitchQ = new THREE.Quaternion().setFromAxisAngle(
|
||||
new THREE.Vector3(1, 0, 0),
|
||||
pitchDelta
|
||||
)
|
||||
q.premultiply(yawQ).multiply(pitchQ).normalize()
|
||||
|
||||
const quat = { x: q.x, y: q.y, z: q.z, w: q.w }
|
||||
return {
|
||||
nextState: { ...state, quaternion: { ...state.quaternion, quat } },
|
||||
updates: [
|
||||
{ fieldName: 'mode.quat_x', value: quat.x },
|
||||
{ fieldName: 'mode.quat_y', value: quat.y },
|
||||
{ fieldName: 'mode.quat_z', value: quat.z },
|
||||
{ fieldName: 'mode.quat_w', value: quat.w }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function rotateSubjectByDrag(
|
||||
state: CameraInfoState,
|
||||
yawDelta: number,
|
||||
pitchDelta: number
|
||||
): LookThroughResult | null {
|
||||
switch (state.mode) {
|
||||
case 'orbit':
|
||||
return rotateOrbit(state, yawDelta, pitchDelta)
|
||||
case 'look_at':
|
||||
return rotateLookAt(state, yawDelta, pitchDelta)
|
||||
case 'quaternion':
|
||||
return rotateQuaternion(state, yawDelta, pitchDelta)
|
||||
}
|
||||
}
|
||||
|
||||
function dollyOrbit(state: CameraInfoState, deltaY: number): LookThroughResult {
|
||||
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
|
||||
const distance = clamp(
|
||||
state.orbit.distance * factor,
|
||||
MIN_DISTANCE,
|
||||
MAX_DISTANCE
|
||||
)
|
||||
return {
|
||||
nextState: { ...state, orbit: { ...state.orbit, distance } },
|
||||
updates: [{ fieldName: 'mode.distance', value: distance }]
|
||||
}
|
||||
}
|
||||
|
||||
function dollyLookAt(
|
||||
state: CameraInfoState,
|
||||
deltaY: number
|
||||
): LookThroughResult | null {
|
||||
const position = new THREE.Vector3(
|
||||
state.lookAt.position.x,
|
||||
state.lookAt.position.y,
|
||||
state.lookAt.position.z
|
||||
)
|
||||
const target = new THREE.Vector3(
|
||||
state.target.x,
|
||||
state.target.y,
|
||||
state.target.z
|
||||
)
|
||||
const offset = position.clone().sub(target)
|
||||
const distance = offset.length()
|
||||
if (distance < 1e-6) return null
|
||||
|
||||
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
|
||||
const nextDistance = Math.max(MIN_DISTANCE, distance * factor)
|
||||
const next = target
|
||||
.clone()
|
||||
.add(offset.multiplyScalar(nextDistance / distance))
|
||||
const nextPosition: THREE.Vector3Like = { x: next.x, y: next.y, z: next.z }
|
||||
return {
|
||||
nextState: { ...state, lookAt: { position: nextPosition } },
|
||||
updates: [
|
||||
{ fieldName: 'mode.position_x', value: nextPosition.x },
|
||||
{ fieldName: 'mode.position_y', value: nextPosition.y },
|
||||
{ fieldName: 'mode.position_z', value: nextPosition.z }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function dollyQuaternion(
|
||||
state: CameraInfoState,
|
||||
deltaY: number
|
||||
): LookThroughResult {
|
||||
const q = normalizeQuaternion(
|
||||
new THREE.Quaternion(
|
||||
state.quaternion.quat.x,
|
||||
state.quaternion.quat.y,
|
||||
state.quaternion.quat.z,
|
||||
state.quaternion.quat.w
|
||||
)
|
||||
)
|
||||
|
||||
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(q)
|
||||
const step = -deltaY * FREE_DOLLY_UNIT
|
||||
const p = state.quaternion.position
|
||||
const nextPosition: THREE.Vector3Like = {
|
||||
x: p.x + forward.x * step,
|
||||
y: p.y + forward.y * step,
|
||||
z: p.z + forward.z * step
|
||||
}
|
||||
return {
|
||||
nextState: {
|
||||
...state,
|
||||
quaternion: { ...state.quaternion, position: nextPosition }
|
||||
},
|
||||
updates: [
|
||||
{ fieldName: 'mode.position_x', value: nextPosition.x },
|
||||
{ fieldName: 'mode.position_y', value: nextPosition.y },
|
||||
{ fieldName: 'mode.position_z', value: nextPosition.z }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function dollyZoom(state: CameraInfoState, deltaY: number): LookThroughResult {
|
||||
const factor = Math.exp(deltaY * DOLLY_EXP_SENSITIVITY)
|
||||
const zoom = clamp(state.zoom / factor, MIN_ZOOM, MAX_ZOOM)
|
||||
return {
|
||||
nextState: { ...state, zoom },
|
||||
updates: [{ fieldName: 'zoom', value: zoom }]
|
||||
}
|
||||
}
|
||||
|
||||
export function dollySubjectByWheel(
|
||||
state: CameraInfoState,
|
||||
deltaY: number
|
||||
): LookThroughResult | null {
|
||||
if (state.cameraType === 'orthographic') return dollyZoom(state, deltaY)
|
||||
switch (state.mode) {
|
||||
case 'orbit':
|
||||
return dollyOrbit(state, deltaY)
|
||||
case 'look_at':
|
||||
return dollyLookAt(state, deltaY)
|
||||
case 'quaternion':
|
||||
return dollyQuaternion(state, deltaY)
|
||||
}
|
||||
}
|
||||
65
src/extensions/core/cameraInfo/types.ts
Normal file
65
src/extensions/core/cameraInfo/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { QuaternionLike, Vector3Like } from 'three'
|
||||
|
||||
export type CameraInfoMode = 'orbit' | 'look_at' | 'quaternion'
|
||||
|
||||
export type CameraInfoCameraType = 'perspective' | 'orthographic'
|
||||
|
||||
export type CameraInfoFieldName =
|
||||
| 'mode.yaw'
|
||||
| 'mode.pitch'
|
||||
| 'mode.distance'
|
||||
| 'target_x'
|
||||
| 'target_y'
|
||||
| 'target_z'
|
||||
| 'mode.position_x'
|
||||
| 'mode.position_y'
|
||||
| 'mode.position_z'
|
||||
| 'mode.quat_x'
|
||||
| 'mode.quat_y'
|
||||
| 'mode.quat_z'
|
||||
| 'mode.quat_w'
|
||||
| 'roll'
|
||||
| 'fov'
|
||||
| 'zoom'
|
||||
|
||||
interface OrbitInputs {
|
||||
yaw: number
|
||||
pitch: number
|
||||
distance: number
|
||||
}
|
||||
|
||||
interface LookAtInputs {
|
||||
position: Vector3Like
|
||||
}
|
||||
|
||||
interface QuaternionInputs {
|
||||
position: Vector3Like
|
||||
quat: QuaternionLike
|
||||
}
|
||||
|
||||
export interface CameraInfoState {
|
||||
mode: CameraInfoMode
|
||||
target: Vector3Like
|
||||
roll: number
|
||||
fov: number
|
||||
zoom: number
|
||||
cameraType: CameraInfoCameraType
|
||||
orbit: OrbitInputs
|
||||
lookAt: LookAtInputs
|
||||
quaternion: QuaternionInputs
|
||||
}
|
||||
|
||||
export const DEFAULT_CAMERA_INFO_STATE: CameraInfoState = {
|
||||
mode: 'orbit',
|
||||
target: { x: 0, y: 0, z: 0 },
|
||||
roll: 0,
|
||||
fov: 35,
|
||||
zoom: 1,
|
||||
cameraType: 'perspective',
|
||||
orbit: { yaw: 35, pitch: 30, distance: 4 },
|
||||
lookAt: { position: { x: 4, y: 4, z: 4 } },
|
||||
quaternion: {
|
||||
position: { x: 4, y: 4, z: 4 },
|
||||
quat: { x: 0, y: 0, z: 0, w: 1 }
|
||||
}
|
||||
}
|
||||
113
src/extensions/core/cameraInfo/widgetBridge.test.ts
Normal file
113
src/extensions/core/cameraInfo/widgetBridge.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_CAMERA_INFO_STATE } from './types'
|
||||
import {
|
||||
readStateFromWidgets,
|
||||
writeWidgetValue,
|
||||
type NodeWithWidgets
|
||||
} from './widgetBridge'
|
||||
|
||||
function nodeWithWidgets(
|
||||
values: Record<string, unknown>
|
||||
): NodeWithWidgets & { widgets: { name: string; value: unknown }[] } {
|
||||
return {
|
||||
widgets: Object.entries(values).map(([name, value]) => ({ name, value }))
|
||||
}
|
||||
}
|
||||
|
||||
describe('readStateFromWidgets', () => {
|
||||
it('returns defaults when the node has no widgets at all', () => {
|
||||
expect(readStateFromWidgets({})).toEqual(DEFAULT_CAMERA_INFO_STATE)
|
||||
})
|
||||
|
||||
it('reads each widget by name (orbit-mode example)', () => {
|
||||
const node = nodeWithWidgets({
|
||||
mode: 'orbit',
|
||||
target_x: 1,
|
||||
target_y: 2,
|
||||
target_z: 3,
|
||||
roll: 10,
|
||||
fov: 50,
|
||||
zoom: 0.8,
|
||||
camera_type: 'perspective',
|
||||
'mode.yaw': 25,
|
||||
'mode.pitch': 40,
|
||||
'mode.distance': 7
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.mode).toBe('orbit')
|
||||
expect(state.target).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(state.roll).toBe(10)
|
||||
expect(state.fov).toBe(50)
|
||||
expect(state.zoom).toBe(0.8)
|
||||
expect(state.orbit).toEqual({ yaw: 25, pitch: 40, distance: 7 })
|
||||
})
|
||||
|
||||
it('falls back when a widget value has the wrong type', () => {
|
||||
const node = nodeWithWidgets({
|
||||
fov: 'not-a-number',
|
||||
mode: 'invalid-mode',
|
||||
camera_type: 42
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.fov).toBe(DEFAULT_CAMERA_INFO_STATE.fov)
|
||||
expect(state.mode).toBe(DEFAULT_CAMERA_INFO_STATE.mode)
|
||||
expect(state.cameraType).toBe(DEFAULT_CAMERA_INFO_STATE.cameraType)
|
||||
})
|
||||
|
||||
it('rejects non-finite numbers (NaN / Infinity)', () => {
|
||||
const node = nodeWithWidgets({
|
||||
'mode.yaw': Number.NaN,
|
||||
'mode.distance': Number.POSITIVE_INFINITY
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.orbit.yaw).toBe(DEFAULT_CAMERA_INFO_STATE.orbit.yaw)
|
||||
expect(state.orbit.distance).toBe(DEFAULT_CAMERA_INFO_STATE.orbit.distance)
|
||||
})
|
||||
|
||||
it('reads quaternion mode widgets', () => {
|
||||
const node = nodeWithWidgets({
|
||||
mode: 'quaternion',
|
||||
'mode.position_x': 1,
|
||||
'mode.position_y': 2,
|
||||
'mode.position_z': 3,
|
||||
'mode.quat_x': 0,
|
||||
'mode.quat_y': 0,
|
||||
'mode.quat_z': 0,
|
||||
'mode.quat_w': 1
|
||||
})
|
||||
|
||||
const state = readStateFromWidgets(node)
|
||||
|
||||
expect(state.mode).toBe('quaternion')
|
||||
expect(state.quaternion.position).toEqual({ x: 1, y: 2, z: 3 })
|
||||
expect(state.quaternion.quat).toEqual({ x: 0, y: 0, z: 0, w: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeWidgetValue', () => {
|
||||
it('updates the named widget when the value differs', () => {
|
||||
const node = nodeWithWidgets({ 'mode.yaw': 0 })
|
||||
writeWidgetValue(node, 'mode.yaw', 45)
|
||||
expect(node.widgets.find((w) => w.name === 'mode.yaw')!.value).toBe(45)
|
||||
})
|
||||
|
||||
it('is a no-op when the value already matches', () => {
|
||||
const node = nodeWithWidgets({ fov: 35 })
|
||||
const before = node.widgets[0].value
|
||||
writeWidgetValue(node, 'fov', 35)
|
||||
expect(node.widgets[0].value).toBe(before)
|
||||
})
|
||||
|
||||
it('is a no-op when the widget does not exist', () => {
|
||||
const node = nodeWithWidgets({ fov: 35 })
|
||||
expect(() => writeWidgetValue(node, 'does_not_exist', 1)).not.toThrow()
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
103
src/extensions/core/cameraInfo/widgetBridge.ts
Normal file
103
src/extensions/core/cameraInfo/widgetBridge.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DEFAULT_CAMERA_INFO_STATE,
|
||||
type CameraInfoCameraType,
|
||||
type CameraInfoMode,
|
||||
type CameraInfoState
|
||||
} from './types'
|
||||
|
||||
interface WidgetLike {
|
||||
name: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export interface NodeWithWidgets {
|
||||
widgets?: WidgetLike[]
|
||||
}
|
||||
|
||||
const VALID_MODES: readonly CameraInfoMode[] = [
|
||||
'orbit',
|
||||
'look_at',
|
||||
'quaternion'
|
||||
]
|
||||
const VALID_CAMERA_TYPES: readonly CameraInfoCameraType[] = [
|
||||
'perspective',
|
||||
'orthographic'
|
||||
]
|
||||
|
||||
function widgetByName(
|
||||
node: NodeWithWidgets,
|
||||
name: string
|
||||
): WidgetLike | undefined {
|
||||
return node.widgets?.find((w) => w.name === name)
|
||||
}
|
||||
|
||||
function num(node: NodeWithWidgets, name: string, fallback: number): number {
|
||||
const v = widgetByName(node, name)?.value
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
|
||||
}
|
||||
|
||||
function pickMode(node: NodeWithWidgets): CameraInfoMode {
|
||||
const v = widgetByName(node, 'mode')?.value
|
||||
return typeof v === 'string' && (VALID_MODES as readonly string[]).includes(v)
|
||||
? (v as CameraInfoMode)
|
||||
: DEFAULT_CAMERA_INFO_STATE.mode
|
||||
}
|
||||
|
||||
function pickCameraType(node: NodeWithWidgets): CameraInfoCameraType {
|
||||
const v = widgetByName(node, 'camera_type')?.value
|
||||
return typeof v === 'string' &&
|
||||
(VALID_CAMERA_TYPES as readonly string[]).includes(v)
|
||||
? (v as CameraInfoCameraType)
|
||||
: DEFAULT_CAMERA_INFO_STATE.cameraType
|
||||
}
|
||||
|
||||
export function readStateFromWidgets(node: NodeWithWidgets): CameraInfoState {
|
||||
const d = DEFAULT_CAMERA_INFO_STATE
|
||||
return {
|
||||
mode: pickMode(node),
|
||||
target: {
|
||||
x: num(node, 'target_x', d.target.x),
|
||||
y: num(node, 'target_y', d.target.y),
|
||||
z: num(node, 'target_z', d.target.z)
|
||||
},
|
||||
roll: num(node, 'roll', d.roll),
|
||||
fov: num(node, 'fov', d.fov),
|
||||
zoom: num(node, 'zoom', d.zoom),
|
||||
cameraType: pickCameraType(node),
|
||||
orbit: {
|
||||
yaw: num(node, 'mode.yaw', d.orbit.yaw),
|
||||
pitch: num(node, 'mode.pitch', d.orbit.pitch),
|
||||
distance: num(node, 'mode.distance', d.orbit.distance)
|
||||
},
|
||||
lookAt: {
|
||||
position: {
|
||||
x: num(node, 'mode.position_x', d.lookAt.position.x),
|
||||
y: num(node, 'mode.position_y', d.lookAt.position.y),
|
||||
z: num(node, 'mode.position_z', d.lookAt.position.z)
|
||||
}
|
||||
},
|
||||
quaternion: {
|
||||
position: {
|
||||
x: num(node, 'mode.position_x', d.quaternion.position.x),
|
||||
y: num(node, 'mode.position_y', d.quaternion.position.y),
|
||||
z: num(node, 'mode.position_z', d.quaternion.position.z)
|
||||
},
|
||||
quat: {
|
||||
x: num(node, 'mode.quat_x', d.quaternion.quat.x),
|
||||
y: num(node, 'mode.quat_y', d.quaternion.quat.y),
|
||||
z: num(node, 'mode.quat_z', d.quaternion.quat.z),
|
||||
w: num(node, 'mode.quat_w', d.quaternion.quat.w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWidgetValue(
|
||||
node: NodeWithWidgets,
|
||||
name: string,
|
||||
value: number | string
|
||||
): void {
|
||||
const widget = widgetByName(node, name)
|
||||
if (!widget || widget.value === value) return
|
||||
widget.value = value
|
||||
}
|
||||
@@ -17,6 +17,9 @@ export class CameraManager implements CameraManagerInterface {
|
||||
|
||||
private controls: OrbitControls | null = null
|
||||
|
||||
private customUp: THREE.Vector3 | null = null
|
||||
private usingCustomUp = false
|
||||
|
||||
DEFAULT_DISTANCE = 10
|
||||
DEFAULT_LOOK_AT = 0
|
||||
|
||||
@@ -171,10 +174,21 @@ export class CameraManager implements CameraManagerInterface {
|
||||
}
|
||||
|
||||
setCameraState(state: CameraState): void {
|
||||
if (state.cameraType && state.cameraType !== this.getCurrentCameraType()) {
|
||||
this.toggleCamera(state.cameraType)
|
||||
}
|
||||
|
||||
this.activeCamera.position.copy(state.position)
|
||||
|
||||
this.controls?.target.copy(state.target)
|
||||
|
||||
if (
|
||||
state.fov !== undefined &&
|
||||
this.activeCamera instanceof THREE.PerspectiveCamera
|
||||
) {
|
||||
this.activeCamera.fov = state.fov
|
||||
}
|
||||
|
||||
if (this.activeCamera instanceof THREE.OrthographicCamera) {
|
||||
this.activeCamera.zoom = state.zoom
|
||||
this.activeCamera.updateProjectionMatrix()
|
||||
@@ -183,9 +197,40 @@ export class CameraManager implements CameraManagerInterface {
|
||||
this.activeCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
if (state.quaternion) {
|
||||
const q = new THREE.Quaternion(
|
||||
state.quaternion.x,
|
||||
state.quaternion.y,
|
||||
state.quaternion.z,
|
||||
state.quaternion.w
|
||||
)
|
||||
const appliedUp = new THREE.Vector3(0, 1, 0).applyQuaternion(q)
|
||||
this.activeCamera.up.copy(appliedUp)
|
||||
this.customUp = appliedUp.clone()
|
||||
this.usingCustomUp = true
|
||||
this.eventManager.emitEvent('cameraUpStateChange', {
|
||||
hasCustomUp: true,
|
||||
usingCustomUp: true
|
||||
})
|
||||
}
|
||||
|
||||
this.controls?.update()
|
||||
}
|
||||
|
||||
setUseCustomUp(use: boolean): void {
|
||||
if (use && !this.customUp) return
|
||||
if (use === this.usingCustomUp) return
|
||||
const target =
|
||||
use && this.customUp ? this.customUp : new THREE.Vector3(0, 1, 0)
|
||||
this.activeCamera.up.copy(target)
|
||||
this.usingCustomUp = use
|
||||
this.controls?.update()
|
||||
this.eventManager.emitEvent('cameraUpStateChange', {
|
||||
hasCustomUp: this.customUp !== null,
|
||||
usingCustomUp: this.usingCustomUp
|
||||
})
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
const aspect = width / height
|
||||
this.updateAspectRatio(aspect)
|
||||
|
||||
@@ -136,7 +136,8 @@ function makeInstance() {
|
||||
eventManager,
|
||||
adapterRef: { current: null },
|
||||
forceRender: vi.fn(),
|
||||
handleResize: vi.fn()
|
||||
handleResize: vi.fn(),
|
||||
postRenderCallbacks: []
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -249,7 +249,6 @@ describe('Viewport3d', () => {
|
||||
expect(overlay.onActiveCameraChange).toHaveBeenCalledWith(
|
||||
ctx.cameraManager.activeCamera
|
||||
)
|
||||
expect(ctx.viewport.getOverlay()).toBe(overlay)
|
||||
})
|
||||
|
||||
it('replacing an overlay detaches and disposes the prior one', () => {
|
||||
@@ -261,18 +260,6 @@ describe('Viewport3d', () => {
|
||||
expect(first.detach).toHaveBeenCalledOnce()
|
||||
expect(first.dispose).toHaveBeenCalledOnce()
|
||||
expect(second.attach).toHaveBeenCalledWith(ctx.sceneManager.scene)
|
||||
expect(ctx.viewport.getOverlay()).toBe(second)
|
||||
})
|
||||
|
||||
it('removeOverlay detaches and disposes the installed overlay', () => {
|
||||
const overlay = makeOverlay()
|
||||
ctx.viewport.setOverlay(overlay)
|
||||
|
||||
ctx.viewport.removeOverlay()
|
||||
|
||||
expect(overlay.detach).toHaveBeenCalledOnce()
|
||||
expect(overlay.dispose).toHaveBeenCalledOnce()
|
||||
expect(ctx.viewport.getOverlay()).toBeNull()
|
||||
})
|
||||
|
||||
it('tickPerFrame forwards delta to the overlay before view-helper/controls update', () => {
|
||||
|
||||
@@ -184,6 +184,7 @@ export class Viewport3d {
|
||||
private renderView(): void {
|
||||
this.view.beginRender()
|
||||
this.renderMainScene()
|
||||
this.runPostRenderCallbacks()
|
||||
|
||||
this.renderer.setScissorTest(false)
|
||||
this.viewHelperManager.render(
|
||||
@@ -194,6 +195,20 @@ export class Viewport3d {
|
||||
this.view.blit()
|
||||
}
|
||||
|
||||
private readonly postRenderCallbacks: Array<() => void> = []
|
||||
|
||||
addPostRenderCallback(cb: () => void): () => void {
|
||||
this.postRenderCallbacks.push(cb)
|
||||
return () => {
|
||||
const i = this.postRenderCallbacks.indexOf(cb)
|
||||
if (i >= 0) this.postRenderCallbacks.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
private runPostRenderCallbacks(): void {
|
||||
for (const cb of this.postRenderCallbacks) cb()
|
||||
}
|
||||
|
||||
protected tickPerFrame(delta: number): void {
|
||||
this.overlay?.update?.(delta)
|
||||
this.viewHelperManager.update(delta)
|
||||
@@ -353,6 +368,11 @@ export class Viewport3d {
|
||||
return this.cameraManager.getCameraState()
|
||||
}
|
||||
|
||||
setUseCustomUp(use: boolean): void {
|
||||
this.cameraManager.setUseCustomUp(use)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.applyTargetSize(width, height)
|
||||
this.handleResize()
|
||||
|
||||
71
src/extensions/core/load3d/createViewport3d.ts
Normal file
71
src/extensions/core/load3d/createViewport3d.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type * as THREE from 'three'
|
||||
|
||||
import { RendererView } from '@/renderer/three/RendererView'
|
||||
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { ViewHelperManager } from './ViewHelperManager'
|
||||
import { Viewport3d } from './Viewport3d'
|
||||
import type { Viewport3dDeps } from './Viewport3d'
|
||||
import type { Load3DOptions } from './interfaces'
|
||||
|
||||
function buildViewport3dDeps(container: HTMLElement): Viewport3dDeps {
|
||||
const view = new RendererView(container)
|
||||
const renderer = view.renderer
|
||||
const eventManager = new EventManager()
|
||||
|
||||
let cameraManager: CameraManager
|
||||
let controlsManager: ControlsManager
|
||||
|
||||
const getActiveCamera = (): THREE.Camera => cameraManager.activeCamera
|
||||
const getControls = () => controlsManager.controls
|
||||
|
||||
const sceneManager = new SceneManager(
|
||||
view,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
cameraManager = new CameraManager(renderer, eventManager)
|
||||
controlsManager = new ControlsManager(
|
||||
container,
|
||||
cameraManager.activeCamera,
|
||||
eventManager
|
||||
)
|
||||
cameraManager.setControls(controlsManager.controls)
|
||||
|
||||
const lightingManager = new LightingManager(sceneManager.scene, eventManager)
|
||||
const viewHelperManager = new ViewHelperManager(
|
||||
renderer,
|
||||
getActiveCamera,
|
||||
getControls,
|
||||
eventManager
|
||||
)
|
||||
|
||||
return {
|
||||
view,
|
||||
eventManager,
|
||||
sceneManager,
|
||||
cameraManager,
|
||||
controlsManager,
|
||||
lightingManager,
|
||||
viewHelperManager
|
||||
}
|
||||
}
|
||||
|
||||
export function createViewport3d(
|
||||
container: HTMLElement,
|
||||
options?: Load3DOptions
|
||||
): Viewport3d {
|
||||
const viewport = new Viewport3d(
|
||||
container,
|
||||
buildViewport3dDeps(container),
|
||||
options
|
||||
)
|
||||
viewport.start()
|
||||
return viewport
|
||||
}
|
||||
@@ -82,6 +82,8 @@ export interface CameraConfig {
|
||||
cameraType: CameraType
|
||||
fov: number
|
||||
state?: CameraState
|
||||
hasCustomUp?: boolean
|
||||
useCustomUp?: boolean
|
||||
}
|
||||
|
||||
export interface LightConfig {
|
||||
@@ -159,6 +161,7 @@ export interface CameraManagerInterface extends BaseManager {
|
||||
setFOV(fov: number): void
|
||||
setCameraState(state: CameraState): void
|
||||
getCameraState(): CameraState
|
||||
setUseCustomUp(use: boolean): void
|
||||
handleResize(width: number, height: number): void
|
||||
setControls(controls: OrbitControls): void
|
||||
}
|
||||
|
||||
@@ -24,3 +24,6 @@ export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
|
||||
|
||||
export const isLoad3dNode = (nodeType: string): boolean =>
|
||||
LOAD3D_ALL_NODES.has(nodeType)
|
||||
|
||||
export const isThreeJsNode = (nodeType: string): boolean =>
|
||||
isLoad3dNode(nodeType) || nodeType === 'CreateCameraInfo'
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useExtensionStore } from '@/stores/extensionStore'
|
||||
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
import { isLoad3dNode } from './load3d/nodeTypes'
|
||||
import { isLoad3dNode, isThreeJsNode } from './load3d/nodeTypes'
|
||||
|
||||
let load3dExtensionsLoaded = false
|
||||
let load3dExtensionsLoading: Promise<ComfyExtension[]> | null = null
|
||||
@@ -39,7 +39,8 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
|
||||
import('./load3d'),
|
||||
import('./load3dAdvanced'),
|
||||
import('./load3dPreviewExtensions'),
|
||||
import('./saveMesh')
|
||||
import('./saveMesh'),
|
||||
import('./cameraInfo')
|
||||
])
|
||||
load3dExtensionsLoaded = true
|
||||
return useExtensionStore().enabledExtensions.filter(
|
||||
@@ -58,6 +59,8 @@ useExtensionService().registerExtension({
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) {
|
||||
if (!isThreeJsNode(nodeData.name)) return
|
||||
|
||||
if (isLoad3dNode(nodeData.name)) {
|
||||
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
|
||||
// Load3D's model_file as a mesh upload widget without hardcoding.
|
||||
@@ -68,14 +71,14 @@ useExtensionService().registerExtension({
|
||||
modelFile[1].upload_subfolder = '3d'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
// since invokeExtensionsAsync already captured the extensions snapshot
|
||||
// before these new extensions were registered.
|
||||
const newExtensions = await loadLoad3dExtensions()
|
||||
for (const ext of newExtensions) {
|
||||
await ext.beforeRegisterNodeDef?.(nodeType, nodeData, app)
|
||||
}
|
||||
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||
// since invokeExtensionsAsync already captured the extensions snapshot
|
||||
// before these new extensions were registered.
|
||||
const newExtensions = await loadLoad3dExtensions()
|
||||
for (const ext of newExtensions) {
|
||||
await ext.beforeRegisterNodeDef?.(nodeType, nodeData, app)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2052,6 +2052,19 @@
|
||||
},
|
||||
"load3d": {
|
||||
"switchCamera": "Switch Camera",
|
||||
"useCustomUp": "Restore camera up from input",
|
||||
"useNaturalUp": "Reset camera up to Y",
|
||||
"showGizmos": "Show gizmos",
|
||||
"hideGizmos": "Hide gizmos",
|
||||
"lookThrough": "Camera view",
|
||||
"exitLookThrough": "Exit camera view",
|
||||
"transformGizmo": {
|
||||
"label": "Edit",
|
||||
"none": "None",
|
||||
"target": "Target",
|
||||
"cameraTranslate": "Cam pos",
|
||||
"cameraRotate": "Cam rot"
|
||||
},
|
||||
"showGrid": "Show Grid",
|
||||
"backgroundColor": "Background Color",
|
||||
"lightIntensity": "Light Intensity",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -57,6 +57,9 @@ const Load3D = defineAsyncComponent(
|
||||
const Load3DAdvanced = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3DAdvanced.vue')
|
||||
)
|
||||
const CameraInfo = defineAsyncComponent(
|
||||
() => import('@/components/cameraInfo/CameraInfo.vue')
|
||||
)
|
||||
const WidgetImageCrop = defineAsyncComponent(
|
||||
() => import('@/components/imagecrop/WidgetImageCrop.vue')
|
||||
)
|
||||
@@ -197,6 +200,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'cameraInfo',
|
||||
{
|
||||
component: CameraInfo,
|
||||
aliases: ['CAMERA_INFO_STATE'],
|
||||
essential: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'imagecrop',
|
||||
{
|
||||
@@ -289,6 +300,7 @@ const EXPANDING_TYPES = [
|
||||
'textPreview',
|
||||
'load3D',
|
||||
'load3DAdvanced',
|
||||
'cameraInfo',
|
||||
'curve',
|
||||
'painter',
|
||||
'imagecompare',
|
||||
|
||||
Reference in New Issue
Block a user