Files
speckle-server/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts
T
Alexandru Popovici f844b6d22e Viewer Navigation (#2416)
* Added model-viewer's orbit camera control implementation. All the features from the old orbit controls are now functional with the new one

* Figured out the relation between radius and fov. Added zoom decelariting when getting closer to origin. This feature was present in the old controller but lacking in this one

* WIP

* Added infinite zooming

* First version of infinite zoom and zoom to cursor

* A few improvements on zoom to cursor. Inifite zoom and zoom to cursor now work together. Simplified the implementation

* Added orthographic camera functionality in the controller without hte special features

* Zoom to cursor now works with orthographic projections. Switching between perspective and orthographic while keeping all functionality correct now works

* Minor cleanup a ton more to come

* Added controls dampening in the sandbox

* Adapted frontend to the new controller plus some additional changes to the controller options in general

* Implemented setting inline and polar viewers. However mosts of the time spent was dedicated into understanding why the frontend checks for changes in the camera each frame, and if it finds one forces the camera viewer to the inline one. I haven't figured out why it's doing this and it seems very wasteful. There probably is a better way of achieving the same thing

* Orbit Controller now computes it;s cartesian value based on spherical in a function so it can be used in multiple places. Replaced getting the current camera position with the target camera position in frontend frame update callback as the old camera controller did

* Follow mode now works properly

* Sandbox fix

* Disabled debug spheres

* WIP on fly controls

* Correct basis transformations for fly controls

* Added QE up/down movement to fly controls

* Minimum radius is now dynamic and accounts for scene size

* Fixed an issue with zooming to cursor when infinetely zooming

* Fixed inconsistencies between zooming normally and infinetely for both zooming to cursors and otherwise

* Further fixing zooming to cursor when infinetely zooming

* Zoom radius modulation

* Fine tuning zooming radius and wheel modulation. I think I got to some defaults that seem good enough for all scene scales.

* Defaulted to 30ms decau time for dampers

* Added a 'tasOnly' argument in all 'intersect' variants. This will only intersect the TAS(es) and provide an intersection result. When requesting an intersection with 'firstOnly' set, onyl the first intersected TAS will be queried further and it's first intersected BAS will provide the intersection result. Conditioned zooming to cursors by needing to have any geometry under the cursor; Fixed some small issues with  functions in the acceleration structures.

* Restricted intersecting onyl when zooming

* Geometry intersection when zooming does not affect infinetely zoom anymore, only zoom to cursor

* Temporary disabled firstHitOnly

* raycastFirst now works correctly in the context of tasOnly param true or false

* Added dampers to the fly controls so we have consistency across various camera controller types.

* Added statinary function

* WIP on controls

* Debuging orbit controller issues on ipad

* More consolidation work on the interface for SpeckleControl and our current implementations for it

* Added controller toggle-ing

* Making the controllers work from immediate data

* There are no more spaces left I can transform from/to. Done them all. Works fine now essentially. Just need to clean stuff up

* SpeckleControls now provide an 'up' vector, and basis transformation is computed that way rather than requirig a mat4 directly. Fixed two issues with event locking on orbit and fly

* More cleanup, more fixes, more grind

* Controllers now have consistency in the basis they provide the position and target into. As well as the fromPositionAndTarget function. It will always be in a basis where (0,1,0) is up. This is required because certain parts of three.js assume that basis

* FlyController is now properly relative-itiez

* Sorted out options for both controls. Dampening is now done via options

* Small fixes for frontend integration

* Fixed the issue with quaternions misaligning around PI

* Added consistent orthographic resizing

* Fixed camera focusing when in orthographic mode

* Implemented disabling/enabling rotations with the new controls. Removed PolarView since it was never used. Canonical views WIP

* Made the HybridCameraController with the simplest implementation possible that seamslessly combines both camera controls. This is for testing to get an idea about how it feels. Removed zoomToCursors when not on geometry. Increased regular zoom speed

* Using constants since move speed is a modifier

* lock fix

* Removed HybridCameraController. Increased maximum zoom. Removed toggling camera controls with Space. New way to apply canonic views, this time a correct one
2024-06-25 15:21:47 +03:00

809 lines
22 KiB
TypeScript

import { difference, flatten, isEqual, uniq } from 'lodash-es'
import { ViewerEvent, VisualDiffMode, CameraController } from '@speckle/viewer'
import type {
PropertyInfo,
StringPropertyInfo,
SunLightConfiguration
} from '@speckle/viewer'
import { useAuthCookie } from '~~/lib/auth/composables/auth'
import type {
Comment,
Project,
ProjectCommentThreadsArgs,
ViewerResourceItem
} from '~~/lib/common/generated/gql/graphql'
import { ProjectCommentsUpdatedMessageType } from '~~/lib/common/generated/gql/graphql'
import {
useInjectedViewer,
useInjectedViewerState
} from '~~/lib/viewer/composables/setup'
import { useViewerSelectionEventHandler } from '~~/lib/viewer/composables/setup/selection'
import {
useGetObjectUrl,
useOnViewerLoadComplete,
useViewerCameraControlStartTracker,
useViewerCameraTracker,
useViewerEventListener
} from '~~/lib/viewer/composables/viewer'
import { useViewerCommentUpdateTracking } from '~~/lib/viewer/composables/commentManagement'
import {
getCacheId,
getObjectReference,
isReference,
modifyObjectFields
} from '~~/lib/common/helpers/graphql'
import type { ModifyFnCacheData } from '~~/lib/common/helpers/graphql'
import {
useViewerOpenedThreadUpdateEmitter,
useViewerThreadTracking
} from '~~/lib/viewer/composables/commentBubbles'
import { useGeneralProjectPageUpdateTracking } from '~~/lib/projects/composables/projectPages'
import { arraysEqual, isNonNullable } from '~~/lib/common/helpers/utils'
import { getTargetObjectIds } from '~~/lib/object-sidebar/helpers'
import { Vector3 } from 'three'
import { areVectorsLooselyEqual } from '~~/lib/viewer/helpers/three'
import { SafeLocalStorage, type Nullable } from '@speckle/shared'
import {
useCameraUtilities,
useMeasurementUtilities
} from '~~/lib/viewer/composables/ui'
import { onKeyStroke, watchTriggerable } from '@vueuse/core'
import { setupDebugMode } from '~~/lib/viewer/composables/setup/dev'
import type { Reference } from '@apollo/client'
import type { Modifier } from '@apollo/client/cache'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
function useViewerIsBusyEventHandler() {
const state = useInjectedViewerState()
const callback = (isBusy: boolean) => {
state.ui.viewerBusy.value = isBusy
}
onMounted(() => {
state.viewer.instance.on(ViewerEvent.Busy, callback)
})
onBeforeUnmount(() => {
state.viewer.instance.removeListener(ViewerEvent.Busy, callback)
})
}
/**
* Automatically loads & unloads objects into the viewer depending on the global URL resource identifier state
*/
function useViewerObjectAutoLoading() {
if (import.meta.server) return
const disableViewerCache =
SafeLocalStorage.get('FE2_FORCE_DISABLE_VIEWER_CACHE') === 'true'
const authToken = useAuthCookie()
const getObjectUrl = useGetObjectUrl()
const {
projectId,
viewer: {
instance: viewer,
init: { ref: isInitialized },
hasDoneInitialLoad
},
resources: {
response: { resourceItems }
},
urlHashState: { focusedThreadId }
} = useInjectedViewerState()
const loadObject = (
objectId: string,
unload?: boolean,
options?: Partial<{ zoomToObject: boolean }>
) => {
const objectUrl = getObjectUrl(projectId.value, objectId)
if (unload) {
viewer.unloadObject(objectUrl)
} else {
viewer.loadObjectAsync(
objectUrl,
authToken.value || undefined,
disableViewerCache ? false : undefined,
options?.zoomToObject
)
}
}
const getUniqueObjectIds = (resourceItems: ViewerResourceItem[]) =>
uniq(resourceItems.map((i) => i.objectId))
watch(
() => <const>[resourceItems.value, isInitialized.value, hasDoneInitialLoad.value],
async ([newResources, newIsInitialized, newHasDoneInitialLoad], oldData) => {
// Wait till viewer loaded in
if (!newIsInitialized) return
const [oldResources] = oldData || [[], false]
const zoomToObject = !focusedThreadId.value // we want to zoom to the thread instead
// Viewer initialized - load in all resources
if (!newHasDoneInitialLoad) {
const allObjectIds = getUniqueObjectIds(newResources)
const res = await Promise.all(
allObjectIds.map((i) => loadObject(i, false, { zoomToObject }))
)
if (res.length) {
hasDoneInitialLoad.value = true
}
return
}
// Resources changed?
const newObjectIds = getUniqueObjectIds(newResources)
const oldObjectIds = getUniqueObjectIds(oldResources)
const removableObjectIds = difference(oldObjectIds, newObjectIds)
const addableObjectIds = difference(newObjectIds, oldObjectIds)
await Promise.all(removableObjectIds.map((i) => loadObject(i, true)))
await Promise.all(
addableObjectIds.map((i) => loadObject(i, false, { zoomToObject: false }))
)
},
{ deep: true, immediate: true }
)
onBeforeUnmount(async () => {
await viewer.unloadAll()
})
}
/**
* Listening to model/version updates through subscriptions and making various
* cache updates so that we don't need to always refetch queries
*/
function useViewerSubscriptionEventTracker() {
if (import.meta.server) return
const {
projectId,
resources: {
request: { resourceIdString, threadFilters }
}
} = useInjectedViewerState()
// Track all project/model/version updates
useGeneralProjectPageUpdateTracking({
projectId
})
// Also track updates to comments
useViewerCommentUpdateTracking(
{
projectId,
resourceIdString,
loadedVersionsOnly: computed(() => threadFilters.value.loadedVersionsOnly)
},
(event, cache) => {
const isArchived = event.type === ProjectCommentsUpdatedMessageType.Archived
const isNew = event.type === ProjectCommentsUpdatedMessageType.Created
const model = event.comment
if (isArchived) {
// Mark as archived
cache.modify({
id: getCacheId('Comment', event.id),
fields: {
archived: () => true
}
})
// Remove from project.commentThreads
modifyObjectFields<ProjectCommentThreadsArgs, Project['commentThreads']>(
cache,
getCacheId('Project', projectId.value),
(fieldName, variables, data) => {
if (fieldName !== 'commentThreads') return
if (variables.filter?.includeArchived) return
const newItems = (data.items || []).filter(
(i) => i.__ref !== getObjectReference('Comment', event.id).__ref
)
return {
...data,
...(data.items ? { items: newItems } : {}),
...(data.totalCount ? { totalCount: data.totalCount - 1 } : {})
}
}
)
} else if (isNew && model) {
const parentId = model.parent?.id
// Add reply to parent
if (parentId) {
cache.modify({
id: getCacheId('Comment', parentId),
fields: {
replies: ((
oldValue: ModifyFnCacheData<Comment['replies']> | Reference
) => {
if (isReference(oldValue)) return oldValue
const newValue: typeof oldValue = {
totalCount: (oldValue?.totalCount || 0) + 1,
items: [
getObjectReference('Comment', model.id),
...(oldValue?.items || [])
]
}
return newValue
}) as Modifier<ModifyFnCacheData<Comment['replies']> | Reference>
}
})
} else {
// Add comment thread
modifyObjectFields<ProjectCommentThreadsArgs, Project['commentThreads']>(
cache,
getCacheId('Project', projectId.value),
(fieldName, _variables, data) => {
if (fieldName !== 'commentThreads') return
const newItems = [
getObjectReference('Comment', model.id),
...(data.items || [])
]
return {
...data,
...(data.items ? { items: newItems } : {}),
...(data.totalCount ? { totalCount: data.totalCount + 1 } : {})
}
}
)
}
}
}
)
}
function useViewerSectionBoxIntegration() {
const {
ui: { sectionBox },
viewer: { instance }
} = useInjectedViewerState()
// No two-way sync for section boxes, because once you set a Box3 into the viewer
// the viewer transforms it into something else causing the updates going into an infinite loop
// state -> viewer
watch(
sectionBox,
(newVal, oldVal) => {
if (newVal && oldVal && newVal.equals(oldVal)) return
if (!newVal && !oldVal) return
if (oldVal && !newVal) {
instance.sectionBoxOff()
instance.requestRender()
return
}
if (newVal && (!oldVal || !newVal.equals(oldVal))) {
instance.setSectionBox({
min: newVal.min,
max: newVal.max
})
instance.sectionBoxOn()
instance.requestRender()
}
},
{ immediate: true, deep: true, flush: 'sync' }
)
onBeforeUnmount(() => {
instance.sectionBoxOff()
})
}
function useViewerCameraIntegration() {
const {
viewer: { instance },
ui: {
camera: { isOrthoProjection, position, target },
spotlightUserSessionId
}
} = useInjectedViewerState()
const { forceViewToViewerSync } = useCameraUtilities()
const hasInitialLoadFired = ref(false)
const loadCameraDataFromViewer = () => {
const extension: CameraController = instance.getExtension(CameraController)
let cameraManuallyChanged = false
const viewerPos = new Vector3().copy(extension.getPosition())
const viewerTarget = new Vector3().copy(extension.getTarget())
if (!areVectorsLooselyEqual(position.value, viewerPos)) {
if (hasInitialLoadFired.value) position.value = viewerPos.clone()
cameraManuallyChanged = true
}
if (!areVectorsLooselyEqual(target.value, viewerTarget)) {
if (hasInitialLoadFired.value) target.value = viewerTarget.clone()
cameraManuallyChanged = true
}
return cameraManuallyChanged
}
// viewer -> state
// debouncing pos/target updates to avoid jitteriness + spotlight mode unnecessarily disabling
useViewerCameraTracker(
() => {
loadCameraDataFromViewer()
}
// { debounceWait: 100 }
)
useOnViewerLoadComplete(({ isInitial }) => {
if (isInitial) {
hasInitialLoadFired.value = true
// Load camera position so we can return to it correctly
// ONLY if we don't already have specific coordinates (e.g. from opened thread)
// otherwise - load current pos/target into viewer
const hasInitCoords =
position.value.equals(new Vector3()) && target.value.equals(new Vector3())
if (hasInitCoords) {
loadCameraDataFromViewer()
} else {
forceViewToViewerSync()
}
// Only now set projection, we can't do it too early
orthoProjectionUpdate(isOrthoProjection.value)
} else {
loadCameraDataFromViewer()
}
})
useViewerCameraControlStartTracker(() => {
if (spotlightUserSessionId.value) {
spotlightUserSessionId.value = null // cancel
}
})
const orthoProjectionUpdate = (newVal: boolean) => {
if (!hasInitialLoadFired.value) {
throw new Error('Attempting to set projection too early')
}
if (newVal) {
instance.setOrthoCameraOn()
} else {
instance.setPerspectiveCameraOn()
}
// reset camera pos, cause we've switched cameras now and it might not have the new ones
forceViewToViewerSync()
}
// state -> viewer
watch(
isOrthoProjection,
(newVal, oldVal) => {
if (newVal === oldVal || !hasInitialLoadFired.value) return
orthoProjectionUpdate(newVal)
},
{ immediate: true }
)
watch(
position,
(newVal, oldVal) => {
if ((!newVal && !oldVal) || (oldVal && areVectorsLooselyEqual(newVal, oldVal))) {
return
}
instance.setView({
position: newVal,
target: target.value
})
}
// { immediate: true }
)
watch(
target,
(newVal, oldVal) => {
if ((!newVal && !oldVal) || (oldVal && areVectorsLooselyEqual(newVal, oldVal))) {
return
}
instance.setView({
position: position.value,
target: newVal
})
}
// { immediate: true }
)
}
function useViewerFiltersIntegration() {
const {
viewer: { instance },
ui: { filters, highlightedObjectIds }
} = useInjectedViewerState()
const {
metadata: { availableFilters: allFilters }
} = useInjectedViewer()
const stateKey = 'default'
let preventFilterWatchers = false
const withWatchersDisabled = (fn: () => void) => {
const isAlreadyInPreventScope = !!preventFilterWatchers
preventFilterWatchers = true
fn()
if (!isAlreadyInPreventScope) preventFilterWatchers = false
}
const speckleTypeFilter = computed(
() => allFilters.value?.find((f) => f.key === 'speckle_type') as StringPropertyInfo
)
// state -> viewer
watch(
highlightedObjectIds,
(newVal, oldVal) => {
if (arraysEqual(newVal, oldVal || [])) return
instance.highlightObjects(newVal)
},
{ immediate: true, flush: 'sync' }
)
watch(
filters.isolatedObjectIds,
(newVal, oldVal) => {
if (preventFilterWatchers) return
if (arraysEqual(newVal, oldVal || [])) return
const isolatable = difference(newVal, oldVal || [])
const unisolatable = difference(oldVal || [], newVal)
if (isolatable.length) {
withWatchersDisabled(() => {
instance.isolateObjects(isolatable, stateKey, true)
filters.hiddenObjectIds.value = []
})
}
if (unisolatable.length) {
withWatchersDisabled(() => {
instance.unIsolateObjects(unisolatable, stateKey, true)
filters.hiddenObjectIds.value = []
})
}
},
{ immediate: true, flush: 'sync' }
)
watch(
filters.hiddenObjectIds,
(newVal, oldVal) => {
if (preventFilterWatchers) return
if (arraysEqual(newVal, oldVal || [])) return
const hidable = difference(newVal, oldVal || [])
const showable = difference(oldVal || [], newVal)
if (hidable.length) {
withWatchersDisabled(() => {
instance.hideObjects(hidable, stateKey, true)
filters.isolatedObjectIds.value = []
})
}
if (showable.length) {
withWatchersDisabled(() => {
instance.showObjects(showable, stateKey, true)
filters.isolatedObjectIds.value = []
})
}
},
{ immediate: true, flush: 'sync' }
)
const syncColorFilterToViewer = async (
filter: Nullable<PropertyInfo>,
isApplied: boolean
) => {
const targetFilter = filter || speckleTypeFilter.value
if (isApplied && targetFilter) await instance.setColorFilter(targetFilter)
if (!isApplied) await instance.removeColorFilter()
}
watch(
() =>
<const>[
filters.propertyFilter.filter.value,
filters.propertyFilter.isApplied.value
],
async (newVal) => {
const [filter, isApplied] = newVal
await syncColorFilterToViewer(filter, isApplied)
},
{ immediate: true, flush: 'sync' }
)
useOnViewerLoadComplete(
async () => {
const targetFilter =
filters.propertyFilter.filter.value || speckleTypeFilter.value
const isApplied = filters.propertyFilter.isApplied.value
await syncColorFilterToViewer(targetFilter, isApplied)
},
{ initialOnly: true }
)
watch(
filters.selectedObjects,
(newVal, oldVal) => {
const newIds = flatten(newVal.map((v) => getTargetObjectIds({ ...v }))).filter(
isNonNullable
)
const oldIds = flatten(
(oldVal || []).map((v) => getTargetObjectIds({ ...v }))
).filter(isNonNullable)
if (arraysEqual(newIds, oldIds)) return
if (!newVal.length) {
instance.resetSelection()
return
}
instance.selectObjects(newIds)
},
{ immediate: true, flush: 'sync' }
)
}
function useLightConfigIntegration() {
const {
ui: { lightConfig },
viewer: { instance }
} = useInjectedViewerState()
// viewer -> state
useViewerEventListener(
ViewerEvent.LightConfigUpdated,
(config: SunLightConfiguration) => {
if (isEqual(lightConfig.value, config)) return
lightConfig.value = config
}
)
// state -> viewer
watch(
lightConfig,
(newVal, oldVal) => {
if (newVal && oldVal && isEqual(newVal, oldVal)) return
instance.setLightConfiguration(newVal)
},
{
immediate: true,
deep: true,
flush: 'sync'
}
)
useOnViewerLoadComplete(
() => {
instance.setLightConfiguration(lightConfig.value)
},
{ initialOnly: true }
)
}
function useExplodeFactorIntegration() {
const {
ui: { explodeFactor },
viewer: { instance }
} = useInjectedViewerState()
// state -> viewer only. we don't need the reverse.
watch(
explodeFactor,
(newVal) => {
/** newVal turns out to be a string. It needs to be a */
instance.explode(newVal)
},
{ immediate: true }
)
useOnViewerLoadComplete(
() => {
instance.explode(explodeFactor.value)
},
{ initialOnly: true }
)
}
function useDiffingIntegration() {
const state = useInjectedViewerState()
const authCookie = useAuthCookie()
const getObjectUrl = useGetObjectUrl()
const hasInitialLoadFired = ref(false)
const { trigger: triggerDiffCommandWatch } = watchTriggerable(
() => <const>[state.ui.diff.oldVersion.value, state.ui.diff.newVersion.value],
async (newVal, oldVal) => {
if (!hasInitialLoadFired.value) return
const [oldVersion, newVersion] = newVal
const [oldOldVersion, oldNewVersion] = oldVal || [null, null]
const versionId = (version: typeof oldOldVersion) => version?.id || null
const commandId = (
oldVersion: typeof oldOldVersion,
newVersion: typeof oldOldVersion
) => {
const oldId = versionId(oldVersion)
const newId = versionId(newVersion)
return oldId && newId ? `${oldId}->${newId}` : null
}
const newCommand = commandId(oldVersion, newVersion)
const oldCommand = commandId(oldOldVersion, oldNewVersion)
if ((newCommand && oldCommand === newCommand) || !!newCommand === !!oldCommand)
return
if (!newCommand || oldVal) {
await state.viewer.instance.undiff()
if (!newCommand) return
}
// values shouldn't be undefined cause commandId() generation succeeded
const oldObjUrl = getObjectUrl(
state.projectId.value,
oldVersion?.referencedObject as string
)
const newObjUrl = getObjectUrl(
state.projectId.value,
newVersion?.referencedObject as string
)
state.ui.diff.result.value = await state.viewer.instance.diff(
oldObjUrl,
newObjUrl,
state.ui.diff.mode.value,
authCookie.value
)
},
{ immediate: true }
)
// const preventWatchers = 0
watch(state.ui.diff.result, (val) => {
if (!val) return
// reset visual diff time and mode on new diff result
// sometimes the watcher won't fire even when the values are updated, because they're updated to
// the same values that they were already. because of that we're manually & forcefully running
// the relevant watchers when diffResult changes
ignoreDiffModeUpdates(() => {
ignoreDiffTimeUpdates(() => {
state.ui.diff.time.value = 0.5
state.ui.diff.mode.value = VisualDiffMode.COLORED
// this watcher also updates diffTime, so no need to invoke that separately
triggerDiffModeWatch()
})
})
})
const { ignoreUpdates: ignoreDiffTimeUpdates } = watchTriggerable(
state.ui.diff.time,
(val) => {
if (!hasInitialLoadFired.value) return
if (!state.ui.diff.result.value) return
state.viewer.instance.setDiffTime(state.ui.diff.result.value, val)
}
)
const { trigger: triggerDiffModeWatch, ignoreUpdates: ignoreDiffModeUpdates } =
watchTriggerable(state.ui.diff.mode, (val) => {
if (!hasInitialLoadFired.value) return
if (!state.ui.diff.result.value) return
state.viewer.instance.setVisualDiffMode(state.ui.diff.result.value, val)
state.viewer.instance.setDiffTime(
state.ui.diff.result.value,
state.ui.diff.time.value
) // hmm, why do i need to call diff time again? seems like a minor viewer bug
})
useOnViewerLoadComplete(({ isInitial }) => {
if (!isInitial) return
hasInitialLoadFired.value = true
triggerDiffCommandWatch()
})
}
function useViewerMeasurementIntegration() {
const {
ui: { measurement },
viewer: { instance }
} = useInjectedViewerState()
const { clearMeasurements, removeMeasurement } = useMeasurementUtilities()
onBeforeUnmount(() => {
clearMeasurements()
})
watch(
() => measurement.enabled.value,
(newVal, oldVal) => {
if (newVal !== oldVal) {
instance.enableMeasurements(newVal)
}
},
{ immediate: true }
)
watch(
() => ({ ...measurement.options.value }),
(newMeasurementState) => {
if (newMeasurementState) {
instance.setMeasurementOptions(newMeasurementState)
}
},
{ immediate: true, deep: true }
)
onKeyStroke('Delete', () => {
removeMeasurement()
})
onKeyStroke('Backspace', () => {
removeMeasurement()
})
}
function useDisableZoomOnEmbed() {
const { viewer } = useInjectedViewerState()
const embedOptions = useEmbed()
watch(
() => embedOptions.noScroll.value,
(newNoScrollValue) => {
newNoScrollValue
viewer
const cameraController: CameraController =
viewer.instance.getExtension(CameraController)
if (newNoScrollValue) {
cameraController.options = { enableZoom: false }
} else {
cameraController.options = { enableZoom: true }
}
},
{ immediate: true }
)
}
export function useViewerPostSetup() {
if (import.meta.server) return
useViewerObjectAutoLoading()
useViewerSelectionEventHandler()
useViewerIsBusyEventHandler()
useViewerSubscriptionEventTracker()
useViewerThreadTracking()
useViewerOpenedThreadUpdateEmitter()
useViewerSectionBoxIntegration()
useViewerCameraIntegration()
useViewerFiltersIntegration()
useLightConfigIntegration()
useExplodeFactorIntegration()
useDiffingIntegration()
useViewerMeasurementIntegration()
useDisableZoomOnEmbed()
setupDebugMode()
}