Files
speckle-server/packages/frontend-2/lib/viewer/composables/setup/postSetup.ts
T

1591 lines
47 KiB
TypeScript

import { difference, flatten, isEqual, uniq } from 'lodash-es'
import {
useThrottleFn,
watchTriggerable,
useMagicKeys,
useEventListener
} from '@vueuse/core'
import {
ViewerEvent,
VisualDiffMode,
CameraController,
DiffExtension,
UpdateFlags,
SectionOutlines,
SectionToolEvent,
SectionTool,
SpeckleLoader,
LargeModelStreamingLoader,
ExplodeEvent,
ExplodeExtension,
LoaderEvent,
SelectionExtension,
type SunLightConfiguration
} from '@speckle/viewer'
import { Box3, Frustum, Matrix4, Vector3, type Camera } from 'three'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import type { ViewerResourceItem } from '~~/lib/common/generated/gql/graphql'
import { ProjectCommentsUpdatedMessageType } from '~~/lib/common/generated/gql/graphql'
import {
useInjectedViewerState,
useInjectedViewerInterfaceState
} 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 {
useCommentContext,
useViewerCommentUpdateTracking
} from '~~/lib/viewer/composables/commentManagement'
import { getCacheId } 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 { areVectorsLooselyEqual } from '~~/lib/viewer/helpers/three'
import { SafeLocalStorage } from '@speckle/shared'
import {
useCameraUtilities,
useSectionBoxUtilities
} from '~~/lib/viewer/composables/ui'
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { setupDebugMode } from '~~/lib/viewer/composables/setup/dev'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
import { useMixpanel } from '~~/lib/core/composables/mp'
import { graphql } from '~/lib/common/generated/gql'
import { useTreeManagement } from '~~/lib/viewer/composables/tree'
import { useViewerSavedViewIntegration } from '~/lib/viewer/composables/savedViews/state'
import { useViewModesPostSetup } from '~/lib/viewer/composables/setup/viewMode'
import { useMeasurementsPostSetup } from '~/lib/viewer/composables/setup/measurements'
import { useFilterColoringPostSetup } from '~/lib/viewer/composables/setup/coloring'
import {
usePropertyFilteringPostSetup,
useManualFilteringPostSetup
} from '~/lib/viewer/composables/setup/filters'
import { useFilterUtilities } from '~/lib/viewer/composables/filtering/filtering'
import { useFilteringSetup } from '~/lib/viewer/composables/filtering/setup'
import {
useHighlightingPostSetup,
HighlightExtension
} from '~/lib/viewer/composables/setup/highlighting'
import { useProjectSavedViewsUpdateTracking } from '~/lib/viewer/composables/savedViews/subscriptions'
function useViewerLoadCompleteEventHandler() {
const state = useInjectedViewerState()
const callback = () => {
state.ui.loading.value = false
}
onMounted(() => {
state.viewer.instance.on(ViewerEvent.LoadComplete, callback)
})
onBeforeUnmount(() => {
state.viewer.instance.removeListener(ViewerEvent.LoadComplete, callback)
})
}
/**
* Automatically loads & unloads objects into the viewer depending on the global URL resource identifier state
*/
function useViewerObjectAutoLoading() {
if (import.meta.server) return
type ViewerDerivativeManifestResponse = {
status: 'missing' | 'queued' | 'processing' | 'ready' | 'failed'
stage?: string | null
progress?: number | null
errorMessage?: string | null
manifest?: {
bounds?: number[]
tiles: Array<{
id?: string
bbox?: number[]
lods: Array<{
level?: number
url: string
bytes?: number
meshes?: number
vertexCount?: number
indexCount?: number
triangleCount?: number
}>
}>
grid?: {
axes?: Array<{
id?: string
label?: string
points?: number[][]
planeNormal?: number[]
}>
}
}
}
type ViewerDerivativeManifest = NonNullable<
ViewerDerivativeManifestResponse['manifest']
>
type ViewerDerivativeTile = ViewerDerivativeManifest['tiles'][number]
type ViewerDerivativeLod = ViewerDerivativeTile['lods'][number]
type LargeModelTileRecord = {
key: string
resource: string
tile: ViewerDerivativeTile
tileIndex: number
lod: ViewerDerivativeLod
priority: number
estimatedMemoryBytes: number
inFrustum: boolean
distance: number
}
type LargeModelStreamingSession = {
stop: () => Promise<void>
}
const disableViewerCache =
SafeLocalStorage.get('FE2_FORCE_DISABLE_VIEWER_CACHE') === 'true'
const { effectiveAuthToken } = useAuthManager()
const { triggerNotification } = useGlobalToast()
const getObjectUrl = useGetObjectUrl()
const apiOrigin = useApiOrigin({ forcePublic: true })
const {
projectId,
viewer: {
instance: viewer,
init: { ref: isInitialized },
hasDoneInitialLoad
},
resources: {
request: {
savedView: { id: savedViewId }
},
response: { resourceItems, savedView }
},
ui: { loadProgress, loading, spotlightUserSessionId, hasLoadedQueuedUpModels },
urlHashState: { focusedThreadId }
} = useInjectedViewerState()
const loadingProgressMap: { [id: string]: number } = {}
const derivativeNotificationKeys = new Set<string>()
const activeLargeModelSessions = new Map<string, LargeModelStreamingSession>()
const LARGE_MODEL_ROOT_RESOURCE_SUFFIX = '::large-model-root'
const LARGE_MODEL_TILE_RESOURCE_SUFFIX = '::large-model-tile'
const DEFAULT_STREAM_CONCURRENCY = 3
const DEFAULT_MEMORY_BUDGET_MB = 1536
const DEFAULT_INITIAL_TILE_COUNT = 4
const DEFAULT_MIN_RESIDENT_TILE_COUNT = 4
const DEFAULT_STREAM_UPDATE_INTERVAL_MS = 700
const LARGE_MODEL_STREAM_CONCURRENCY_KEY =
'SPECKLE_VIEWER_LARGE_MODEL_STREAM_CONCURRENCY'
const LARGE_MODEL_MEMORY_BUDGET_MB_KEY = 'SPECKLE_VIEWER_LARGE_MODEL_MEMORY_BUDGET_MB'
const LARGE_MODEL_INITIAL_TILE_COUNT_KEY =
'SPECKLE_VIEWER_LARGE_MODEL_INITIAL_TILE_COUNT'
const LARGE_MODEL_MIN_RESIDENT_TILE_COUNT_KEY =
'SPECKLE_VIEWER_LARGE_MODEL_MIN_RESIDENT_TILE_COUNT'
const getLargeModelNumberSetting = (storageKey: string, defaultValue: number) => {
let configuredValue: unknown
try {
configuredValue = (globalThis as Record<string, unknown>)[storageKey]
configuredValue ??= SafeLocalStorage.get(storageKey)
} catch {
// localStorage/global overrides may be unavailable in embedded runtimes.
}
const parsedValue = Number.parseFloat(`${configuredValue ?? ''}`)
return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : defaultValue
}
const getTileId = (tile: ViewerDerivativeTile, index: number) =>
tile.id || `tile-${index}`
const getTileKey = (
tile: ViewerDerivativeTile,
lod: ViewerDerivativeLod,
index: number
) => `${getTileId(tile, index)}:lod:${lod.level ?? 0}`
const getTileResource = (
objectUrl: string,
tile: ViewerDerivativeTile,
lod: ViewerDerivativeLod,
index: number
) =>
`${objectUrl}${LARGE_MODEL_TILE_RESOURCE_SUFFIX}:${encodeURIComponent(
getTileId(tile, index)
)}:lod:${lod.level ?? 0}`
const getBoundsBox = (bounds?: number[]) => {
if (!bounds || bounds.length < 6) return null
const min = new Vector3(bounds[0], bounds[1], bounds[2])
const max = new Vector3(bounds[3], bounds[4], bounds[5])
if (
!Number.isFinite(min.x) ||
!Number.isFinite(min.y) ||
!Number.isFinite(min.z) ||
!Number.isFinite(max.x) ||
!Number.isFinite(max.y) ||
!Number.isFinite(max.z)
) {
return null
}
return new Box3(min, max)
}
const getCameraFrustum = (camera: Camera) => {
camera.updateMatrixWorld(true)
return new Frustum().setFromProjectionMatrix(
new Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
)
}
const getCameraViewDirection = (camera: Camera) => {
const direction = new Vector3()
camera.getWorldDirection(direction)
return direction
}
const getProjectedScreenDistance = (point: Vector3, camera: Camera) => {
const projected = point.clone().project(camera)
if (
!Number.isFinite(projected.x) ||
!Number.isFinite(projected.y) ||
!Number.isFinite(projected.z)
) {
return Number.MAX_SAFE_INTEGER
}
return projected.x * projected.x + projected.y * projected.y
}
const sortLods = (tile: ViewerDerivativeTile) =>
tile.lods
.filter((lod) => !!lod.url)
.slice()
.sort((a, b) => {
const levelA = a.level ?? 0
const levelB = b.level ?? 0
if (levelA !== levelB) return levelA - levelB
return (a.bytes ?? 0) - (b.bytes ?? 0)
})
const estimateLodMemoryBytes = (lod: ViewerDerivativeLod) => {
const geometryBytes =
(lod.vertexCount ?? 0) * 3 * Float32Array.BYTES_PER_ELEMENT * 4 +
(lod.indexCount ?? 0) * Uint32Array.BYTES_PER_ELEMENT * 3
if (geometryBytes > 0) return geometryBytes
return Math.max(lod.bytes ?? 0, 1) * 4
}
const selectLodForCamera = (tile: ViewerDerivativeTile, camera: Camera | null) => {
const lods = sortLods(tile)
if (lods.length <= 1) return lods[0]
const tileBox = getBoundsBox(tile.bbox)
if (!camera || !tileBox) return lods[0]
const size = tileBox.getSize(new Vector3()).length()
const distance = Math.max(tileBox.distanceToPoint(camera.position), 0)
const normalizedDistance = distance / Math.max(size, 1)
if (normalizedDistance < 2.5) return lods[0]
if (normalizedDistance < 8) return lods[Math.floor((lods.length - 1) / 2)]
return lods[lods.length - 1]
}
const selectLargeModelTileRecords = (
objectUrl: string,
manifest: ViewerDerivativeManifest
) => {
const camera = viewer.getRenderer().renderingCamera
const frustum = camera ? getCameraFrustum(camera) : null
const cameraDirection = camera ? getCameraViewDirection(camera) : null
const manifestBox = getBoundsBox(manifest.bounds)
const modelCenter = manifestBox?.getCenter(new Vector3()) ?? new Vector3()
const modelSize = manifestBox?.getSize(new Vector3()).length() ?? 1
const nearDistance = Math.max(modelSize * 0.35, 1)
const candidates = manifest.tiles
.map((tile, index): LargeModelTileRecord | null => {
const lod = selectLodForCamera(tile, camera)
if (!lod?.url) return null
const tileBox = getBoundsBox(tile.bbox)
const center = tileBox?.getCenter(new Vector3()) ?? modelCenter
const tileSize = tileBox?.getSize(new Vector3()).length() ?? modelSize
const tileRadius = tileSize * 0.5
const distance = camera
? tileBox
? tileBox.distanceToPoint(camera.position)
: camera.position.distanceTo(center)
: center.distanceTo(modelCenter)
const inFrustum = !!(frustum && tileBox && frustum.intersectsBox(tileBox))
const viewDepth =
camera && cameraDirection
? cameraDirection.dot(center.clone().sub(camera.position))
: 0
const inFrontOfCamera = !camera || viewDepth >= -tileRadius
const nearCamera = camera ? inFrontOfCamera && distance <= nearDistance : true
const visibilityRank = inFrustum ? 0 : nearCamera ? 1 : 2
const screenDistance =
camera && inFrontOfCamera
? getProjectedScreenDistance(center, camera)
: Number.MAX_SAFE_INTEGER
const key = getTileKey(tile, lod, index)
const priority = camera
? visibilityRank * 1_000_000_000_000 +
distance * 1_000_000 +
screenDistance * 1_000 +
Math.max(viewDepth, 0) +
index * 0.001
: center.distanceToSquared(modelCenter)
return {
key,
resource: getTileResource(objectUrl, tile, lod, index),
tile,
tileIndex: index,
lod,
priority,
estimatedMemoryBytes: estimateLodMemoryBytes(lod),
inFrustum,
distance
}
})
.filter(isNonNullable)
.sort((a, b) => a.priority - b.priority)
const primaryCandidates =
candidates.filter(
(candidate) => candidate.inFrustum || candidate.priority < 2_000_000_000_000
).length > 0
? candidates.filter(
(candidate) => candidate.inFrustum || candidate.priority < 2_000_000_000_000
)
: candidates
const memoryBudgetBytes =
getLargeModelNumberSetting(
LARGE_MODEL_MEMORY_BUDGET_MB_KEY,
DEFAULT_MEMORY_BUDGET_MB
) *
1024 *
1024
const minResidentTiles = Math.floor(
getLargeModelNumberSetting(
LARGE_MODEL_MIN_RESIDENT_TILE_COUNT_KEY,
DEFAULT_MIN_RESIDENT_TILE_COUNT
)
)
const selected: LargeModelTileRecord[] = []
let selectedBytes = 0
for (const candidate of primaryCandidates) {
const nextBytes = selectedBytes + candidate.estimatedMemoryBytes
if (
selected.length < Math.max(minResidentTiles, 1) ||
nextBytes <= memoryBudgetBytes
) {
selected.push(candidate)
selectedBytes = nextBytes
}
}
return selected
}
viewer.on(ViewerEvent.LoadComplete, (id) => {
delete loadingProgressMap[id]
consolidateProgressInternal({ id, progress: 1 })
})
const consolidateProgressInternal = (args: { progress: number; id: string }) => {
if (args.progress >= 1) {
delete loadingProgressMap[args.id]
} else {
loadingProgressMap[args.id] = args.progress
}
const values = Object.values(loadingProgressMap)
const min = values.length ? Math.min(...values) : 1
loadProgress.value = min
loading.value = min < 1
}
const consolidateProgressThorttled = useThrottleFn(consolidateProgressInternal, 250)
const getViewerDerivativeManifest = async (
versionId: string
): Promise<ViewerDerivativeManifestResponse | undefined> => {
const res = await fetch(
new URL(
`/api/viewer-derivatives/${projectId.value}/${versionId}/manifest`,
apiOrigin
),
{
headers: effectiveAuthToken.value
? { Authorization: `Bearer ${effectiveAuthToken.value}` }
: undefined,
cache: 'no-store'
}
)
if (res.status === 404) return undefined
if (!res.ok) return undefined
return (await res.json()) as ViewerDerivativeManifestResponse
}
const createLargeModelRootManifest = (
manifest: ViewerDerivativeManifest
): ViewerDerivativeManifest => ({
bounds: manifest.bounds,
grid: manifest.grid,
tiles: []
})
const createLargeModelTileManifest = (
manifest: ViewerDerivativeManifest,
tile: ViewerDerivativeTile,
lod: ViewerDerivativeLod
): ViewerDerivativeManifest => ({
bounds: manifest.bounds,
tiles: [
{
id: tile.id,
bbox: tile.bbox,
lods: [lod]
}
]
})
const loadLargeModelResource = async (args: {
resource: string
manifest: ViewerDerivativeManifest
artifactBaseUrl: string
zoomToObject?: boolean
}) => {
const loader = new LargeModelStreamingLoader(
viewer.getWorldTree(),
args.resource,
args.manifest,
args.artifactBaseUrl,
effectiveAuthToken.value || undefined
)
loader.on(LoaderEvent.LoadProgress, (progressArgs) => {
consolidateProgressThorttled(progressArgs)
})
loader.on(LoaderEvent.LoadCancelled, (id) => {
delete loadingProgressMap[id]
consolidateProgressInternal({ id, progress: 1 })
})
await viewer.loadObject(loader, args.zoomToObject)
consolidateProgressInternal({ id: args.resource, progress: 1 })
}
const startLargeModelStreamingSession = async (args: {
objectUrl: string
manifest: ViewerDerivativeManifest
artifactBaseUrl: string
zoomToObject?: boolean
}) => {
await activeLargeModelSessions.get(args.objectUrl)?.stop()
let stopped = false
let zoomPending = !!args.zoomToObject
let updateTimer: number | undefined
let queue: LargeModelTileRecord[] = []
const loadedTiles = new Map<string, LargeModelTileRecord>()
const loadingTiles = new Set<string>()
const resources = new Set<string>()
const rootResource = `${args.objectUrl}${LARGE_MODEL_ROOT_RESOURCE_SUFFIX}`
const streamConcurrency = Math.floor(
getLargeModelNumberSetting(
LARGE_MODEL_STREAM_CONCURRENCY_KEY,
DEFAULT_STREAM_CONCURRENCY
)
)
const updateStreamingProgress = () => {
const pendingTileCount = queue.length + loadingTiles.size
if (stopped || pendingTileCount <= 0) {
consolidateProgressInternal({ id: args.objectUrl, progress: 1 })
return
}
const loadedTileCount = loadedTiles.size
const totalTileCount = Math.max(loadedTileCount + pendingTileCount, 1)
const progress = Math.min(Math.max(loadedTileCount / totalTileCount, 0.02), 0.98)
consolidateProgressInternal({ id: args.objectUrl, progress })
}
const unloadResource = async (resource: string) => {
try {
await viewer.cancelLoad(resource, true)
} catch {
await viewer.unloadObject(resource).catch(() => undefined)
}
}
const loadTileRecord = async (record: LargeModelTileRecord) => {
if (stopped || loadedTiles.has(record.key) || loadingTiles.has(record.key)) {
return
}
loadingTiles.add(record.key)
resources.add(record.resource)
updateStreamingProgress()
try {
await loadLargeModelResource({
resource: record.resource,
manifest: createLargeModelTileManifest(
args.manifest,
record.tile,
record.lod
),
artifactBaseUrl: args.artifactBaseUrl,
zoomToObject: zoomPending
})
zoomPending = false
if (stopped) {
await unloadResource(record.resource)
resources.delete(record.resource)
return
}
loadedTiles.set(record.key, record)
} catch (err) {
if (!stopped) {
// Keep normal model loading alive if one tile fails; the next update can retry.
console.error('Failed to stream large-model tile', record.key, err)
}
resources.delete(record.resource)
consolidateProgressInternal({ id: record.resource, progress: 1 })
} finally {
loadingTiles.delete(record.key)
pumpQueue()
updateStreamingProgress()
}
}
const pumpQueue = () => {
if (stopped) return
while (loadingTiles.size < Math.max(streamConcurrency, 1) && queue.length > 0) {
const record = queue.shift()
if (!record) continue
void loadTileRecord(record)
}
updateStreamingProgress()
}
const unloadFarTiles = async (desiredRecords: LargeModelTileRecord[]) => {
const desiredKeys = new Set(desiredRecords.map((record) => record.key))
const memoryBudgetBytes =
getLargeModelNumberSetting(
LARGE_MODEL_MEMORY_BUDGET_MB_KEY,
DEFAULT_MEMORY_BUDGET_MB
) *
1024 *
1024
const minResidentTiles = Math.max(
1,
Math.floor(
getLargeModelNumberSetting(
LARGE_MODEL_MIN_RESIDENT_TILE_COUNT_KEY,
DEFAULT_MIN_RESIDENT_TILE_COUNT
)
)
)
const removable = [...loadedTiles.values()]
.filter((record) => !desiredKeys.has(record.key))
.sort((a, b) => b.priority - a.priority)
for (const record of removable) {
loadedTiles.delete(record.key)
resources.delete(record.resource)
await viewer.unloadObject(record.resource)
}
let loadedBytes = [...loadedTiles.values()].reduce(
(sum, record) => sum + record.estimatedMemoryBytes,
0
)
const worstLoaded = [...loadedTiles.values()].sort(
(a, b) => b.priority - a.priority
)
for (const record of worstLoaded) {
if (loadedBytes <= memoryBudgetBytes || loadedTiles.size <= minResidentTiles) {
break
}
loadedTiles.delete(record.key)
resources.delete(record.resource)
loadedBytes -= record.estimatedMemoryBytes
await viewer.unloadObject(record.resource)
}
}
const updateDesiredTiles = async () => {
if (stopped) return
const desiredRecords = selectLargeModelTileRecords(args.objectUrl, args.manifest)
const desiredKeys = new Set(desiredRecords.map((record) => record.key))
queue = queue
.filter((record) => desiredKeys.has(record.key))
.sort((a, b) => a.priority - b.priority)
await unloadFarTiles(desiredRecords)
const queuedKeys = new Set(queue.map((record) => record.key))
for (const record of desiredRecords) {
if (
loadedTiles.has(record.key) ||
loadingTiles.has(record.key) ||
queuedKeys.has(record.key)
) {
continue
}
queue.push(record)
queuedKeys.add(record.key)
}
queue.sort((a, b) => a.priority - b.priority)
pumpQueue()
}
const stop = async () => {
stopped = true
if (updateTimer) window.clearInterval(updateTimer)
queue = []
await Promise.all(
[...resources].map(async (resource) => {
await unloadResource(resource)
})
)
resources.clear()
loadedTiles.clear()
loadingTiles.clear()
delete loadingProgressMap[args.objectUrl]
consolidateProgressInternal({ id: args.objectUrl, progress: 1 })
}
const session: LargeModelStreamingSession = { stop }
activeLargeModelSessions.set(args.objectUrl, session)
resources.add(rootResource)
await loadLargeModelResource({
resource: rootResource,
manifest: createLargeModelRootManifest(args.manifest),
artifactBaseUrl: args.artifactBaseUrl,
zoomToObject: false
})
if (stopped) return session
const initialTileCount = Math.max(
1,
Math.floor(
getLargeModelNumberSetting(
LARGE_MODEL_INITIAL_TILE_COUNT_KEY,
DEFAULT_INITIAL_TILE_COUNT
)
)
)
const initialRecords = selectLargeModelTileRecords(
args.objectUrl,
args.manifest
).slice(0, initialTileCount)
queue = initialRecords.slice()
pumpQueue()
updateTimer = window.setInterval(() => {
void updateDesiredTiles()
}, DEFAULT_STREAM_UPDATE_INTERVAL_MS)
void updateDesiredTiles()
updateStreamingProgress()
return session
}
const maybeLoadViewerDerivative = async (
objectId: string,
options?: Partial<{ zoomToObject: boolean }>
) => {
const resourceItem = resourceItems.value.find(
(item) => item.objectId === objectId && item.versionId
)
if (!resourceItem?.versionId) return false
const derivative = await getViewerDerivativeManifest(resourceItem.versionId)
if (!derivative || derivative.status === 'missing') return false
const notificationKey = `${resourceItem.versionId}:${derivative.status}`
if (!derivativeNotificationKeys.has(notificationKey)) {
derivativeNotificationKeys.add(notificationKey)
triggerNotification({
type:
derivative.status === 'failed'
? ToastNotificationType.Danger
: ToastNotificationType.Info,
title: 'Large model streaming',
description:
derivative.status === 'ready'
? 'Loading viewer derivative.'
: derivative.status === 'failed'
? derivative.errorMessage || 'Viewer derivative generation failed.'
: 'Viewer derivative generation is still processing.'
})
}
if (derivative.status === 'ready' && derivative.manifest) {
const objectUrl = getObjectUrl(projectId.value, objectId)
const artifactBaseUrl = new URL(
`/api/viewer-derivatives/${projectId.value}/${resourceItem.versionId}/artifacts`,
apiOrigin
).toString()
await startLargeModelStreamingSession({
objectUrl,
manifest: derivative.manifest,
artifactBaseUrl,
zoomToObject: options?.zoomToObject
})
consolidateProgressInternal({ id: objectUrl, progress: 1 })
return true
}
consolidateProgressInternal({ id: objectId, progress: 1 })
return true
}
const loadObject = async (
objectId: string,
unload?: boolean,
options?: Partial<{ zoomToObject: boolean }>
) => {
const objectUrl = getObjectUrl(projectId.value, objectId)
if (unload) {
const activeLargeModelSession = activeLargeModelSessions.get(objectUrl)
if (activeLargeModelSession) {
activeLargeModelSessions.delete(objectUrl)
await activeLargeModelSession.stop()
return
}
return viewer.unloadObject(objectUrl)
} else {
if (await maybeLoadViewerDerivative(objectId, options)) return
const loader = new SpeckleLoader(
viewer.getWorldTree(),
objectUrl,
effectiveAuthToken.value || undefined,
disableViewerCache ? false : undefined,
undefined
)
loader.on(LoaderEvent.LoadProgress, (args) => {
consolidateProgressThorttled(args)
})
loader.on(LoaderEvent.LoadCancelled, (id) => {
delete loadingProgressMap[id]
consolidateProgressInternal({ id, progress: 1 })
})
loader.on(LoaderEvent.LoadWarning, ({ message }) => {
if (!message.startsWith('Viewer full-load guard:')) return
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Large model needs streaming',
description: message
})
})
return viewer.loadObject(loader, options?.zoomToObject)
}
}
const getUniqueObjectIds = (resourceItems: ViewerResourceItem[]) =>
uniq(resourceItems.map((i) => i.objectId))
const activeLoads = new Set<Promise<void>>()
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]
// we dont want to zoom to object, if we're loading specific coords because of a thread,
// or spotlight mode or a saved view etc.
const preventZooming =
focusedThreadId.value ||
savedViewId.value ||
savedView.value ||
spotlightUserSessionId.value
const zoomToObject = !preventZooming
// Viewer initialized - load in all resources
if (!newHasDoneInitialLoad) {
const allObjectIds = getUniqueObjectIds(newResources)
if (allObjectIds.length) {
// only mark, if anything to load
hasLoadedQueuedUpModels.value = false
}
/** Load sequentially */
const res = []
const loadAll = async () => {
for (const i of allObjectIds) {
res.push(await loadObject(i, false, { zoomToObject }))
}
}
// Register for accurate 'is anything loading' reporting
const promise = loadAll().then(() => {
activeLoads.delete(promise)
})
activeLoads.add(promise)
await promise
if (res.length) {
hasDoneInitialLoad.value = true
if (!activeLoads.size) hasLoadedQueuedUpModels.value = true
}
return
}
// Resources changed?
const loadAndUnloadChanged = async () => {
const newObjectIds = getUniqueObjectIds(newResources)
const oldObjectIds = getUniqueObjectIds(oldResources)
const removableObjectIds = difference(oldObjectIds, newObjectIds)
const addableObjectIds = difference(newObjectIds, oldObjectIds)
if (addableObjectIds.length) {
// only mark, if anything to load
hasLoadedQueuedUpModels.value = false
}
await Promise.all(removableObjectIds.map((i) => loadObject(i, true)))
await Promise.all(
addableObjectIds.map((i) => loadObject(i, false, { zoomToObject: false }))
)
}
// Register for accurate 'is anything loading' reporting
const promise = loadAndUnloadChanged().then(() => {
activeLoads.delete(promise)
})
activeLoads.add(promise)
await promise
if (!activeLoads.size) hasLoadedQueuedUpModels.value = true
},
{ deep: true, immediate: true }
)
onBeforeUnmount(async () => {
await Promise.all(
[...activeLargeModelSessions.values()].map((session) => session.stop())
)
activeLargeModelSessions.clear()
await viewer.unloadAll()
})
}
/**
* Here we make the viewer pretend it's a connector and send out receive events. Note, this is important for us to track to be able to get a picture of how much data is consumed
* in our viewer.
*/
function useViewerReceiveTracking() {
//
const {
resources: {
response: { modelsAndVersionIds }
}
} = useInjectedViewerState()
const mixpanel = useMixpanel()
const { userId } = useActiveUser()
const receivedVersions = new Set<string>()
watch(modelsAndVersionIds, (newVal) => {
for (const { model, versionId } of newVal) {
if (receivedVersions.has(versionId)) {
continue
}
receivedVersions.add(versionId)
mixpanel.track('Receive', {
hostApp: 'viewer',
sourceHostApp: model.loadedVersion.items[0].sourceApplication,
isMultiplayer: model.loadedVersion.items[0].authorUser?.id !== userId.value
})
}
})
}
/**
* 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
})
// Track saved views
useProjectSavedViewsUpdateTracking({ 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 comment = event.comment
if (isArchived) {
// Mark as archived
cache.modify({
id: getCacheId('Comment', event.id),
fields: {
archived: () => true
}
})
// Remove from project.commentThreads
modifyObjectField(
cache,
getCacheId('Project', projectId.value),
'commentThreads',
({ variables, helpers: { createUpdatedValue, readField } }) => {
if (variables.filter?.includeArchived) return // we want it in that list
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount - 1)
update('items', (items) =>
items.filter((i) => readField(i, 'id') !== event.id)
)
})
}
)
} else if (isNew && comment) {
const parentId = comment.parent?.id
// Add reply to parent
if (parentId) {
modifyObjectField(
cache,
getCacheId('Comment', parentId),
'replies',
({ helpers: { createUpdatedValue, ref } }) =>
createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount + 1)
update('items', (items) => [ref('Comment', comment.id), ...items])
})
)
} else {
// Add comment thread
modifyObjectField(
cache,
getCacheId('Project', projectId.value),
'commentThreads',
({ helpers: { ref, createUpdatedValue, readField }, value }) => {
// In case this is actually an unarchived comment, we only want to add it if it doesnt
// exist in the includesArchived list already
const includesItem = value.items?.find(
(i) => readField(i, 'id') === comment.id
)
if (includesItem) return
return createUpdatedValue(({ update }) => {
update('totalCount', (totalCount) => totalCount + 1)
update('items', (items) => [ref('Comment', comment.id), ...items])
})
}
)
}
}
}
)
}
function useViewerSectionBoxIntegration() {
const {
ui: {
sectionBox,
sectionBoxContext: { visible, edited }
},
viewer: { instance }
} = useInjectedViewerState()
const { sectionBoxDataToBox3, sectionBoxDataEquals } = useSectionBoxUtilities()
// Change edited=true when user starts changing the section box by dragging it
const sectionTool = instance.getExtension(SectionTool)
const onDragStart = () => {
edited.value = true
}
sectionTool.on(SectionToolEvent.DragStart, onDragStart)
// 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 && sectionBoxDataEquals(newVal, oldVal)) return
if (!newVal && !oldVal) return
if (oldVal && !newVal) {
visible.value = false
edited.value = false
sectionTool.enabled = false
instance.requestRender(UpdateFlags.RENDER_RESET)
return
}
if (newVal && (!oldVal || !sectionBoxDataEquals(newVal, oldVal))) {
visible.value = true
edited.value = false
const box3 = sectionBoxDataToBox3(newVal)
sectionTool.setBox(box3)
sectionTool.enabled = true
const outlines = instance.getExtension(SectionOutlines)
if (outlines) outlines.requestUpdate()
instance.requestRender(UpdateFlags.RENDER_RESET)
}
},
{ immediate: true, deep: true, flush: 'sync' }
)
watch(
visible,
(newVal, oldVal) => {
if (newVal && oldVal) return
if (!newVal && !oldVal) return
if (newVal) {
sectionTool.visible = true
} else {
sectionTool.visible = false
}
instance.requestRender()
},
{ immediate: true, deep: true, flush: 'sync' }
)
onBeforeUnmount(() => {
sectionTool.enabled = false
sectionTool.removeListener(SectionToolEvent.DragStart, onDragStart)
})
}
function useViewerCameraIntegration() {
const {
viewer: { instance },
ui: {
camera: { isOrthoProjection, position, target },
spotlightUserSessionId
}
} = useInjectedViewerState()
const { forceViewToViewerSync, setView, cameraController } = useCameraUtilities()
const hasInitialLoadFired = ref(false)
const loadCameraDataFromViewer = () => {
const extension: CameraController = instance.getExtension(CameraController)
const viewerPos = new Vector3().copy(extension.getPosition())
const viewerTarget = new Vector3().copy(extension.getTarget())
if (hasInitialLoadFired.value) {
if (!areVectorsLooselyEqual(position.value, viewerPos)) {
position.value = viewerPos.clone()
}
if (!areVectorsLooselyEqual(target.value, viewerTarget)) {
target.value = viewerTarget.clone()
}
}
}
// viewer -> state
// debouncing pos/target updates to avoid jitteriness + spotlight mode unnecessarily disabling
useViewerCameraTracker(
() => {
loadCameraDataFromViewer()
},
{ throttleWait: 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) {
cameraController.setOrthoCameraOn()
} else {
cameraController.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
}
setView({
position: newVal,
target: target.value
})
}
// { immediate: true }
)
watch(
target,
(newVal, oldVal) => {
if ((!newVal && !oldVal) || (oldVal && areVectorsLooselyEqual(newVal, oldVal))) {
return
}
setView({
position: position.value,
target: newVal
})
}
// { immediate: true }
)
}
function useViewerFiltersIntegration() {
const state = useInjectedViewerState()
const {
viewer: { instance },
ui: { filters }
} = state
useFilteringSetup()
useFilterUtilities({ state })
watch(
filters.selectedObjects,
(newVal, oldVal) => {
if (state.ui.windowSelection.enabled.value) return
const newIds = flatten(
newVal.map((v) => getTargetObjectIds(v as Record<string, unknown>))
).filter(isNonNullable)
const oldIds = flatten(
(oldVal || []).map((v) => getTargetObjectIds(v as Record<string, unknown>))
).filter(isNonNullable)
if (arraysEqual(newIds, oldIds)) return
const selectionExtension = instance.getExtension(SelectionExtension)
const currentViewerSelection = selectionExtension.getSelectedObjectIds()
if (
currentViewerSelection.length === newIds.length &&
difference(currentViewerSelection, newIds).length === 0
) {
return
}
state.ui.highlightedObjectIds.value = []
const highlightExtension = instance.getExtension(HighlightExtension)
if (highlightExtension) {
highlightExtension.clearSelection()
}
selectionExtension.clearSelection()
if (newVal.length > 0) {
selectionExtension.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()
const explodeExtension = instance.getExtension(ExplodeExtension)
const updateOutlines = () => {
const sectionOutlines = instance.getExtension(SectionOutlines)
if (sectionOutlines && sectionOutlines.enabled) sectionOutlines.requestUpdate(true)
}
onMounted(() => {
explodeExtension.on(ExplodeEvent.Finshed, updateOutlines)
})
onBeforeUnmount(() => {
explodeExtension.removeListener(ExplodeEvent.Finshed, updateOutlines)
})
// state -> viewer only. we don't need the reverse.
watch(
explodeFactor,
(newVal) => {
explodeExtension.setExplode(newVal)
},
{ immediate: true }
)
useOnViewerLoadComplete(
() => {
explodeExtension.setExplode(explodeFactor.value)
},
{ initialOnly: true }
)
}
function useDiffingIntegration() {
const state = useInjectedViewerState()
const { effectiveAuthToken } = useAuthManager()
const getObjectUrl = useGetObjectUrl()
const hasInitialLoadFired = ref(false)
const diffExtension = state.viewer.instance.getExtension(DiffExtension)
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 diffExtension.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 diffExtension.diff(
oldObjUrl,
newObjUrl,
state.ui.diff.mode.value,
effectiveAuthToken.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
diffExtension.updateVisualDiff(val, state.ui.diff.mode.value)
}
)
const { trigger: triggerDiffModeWatch, ignoreUpdates: ignoreDiffModeUpdates } =
watchTriggerable(state.ui.diff.mode, (val) => {
if (!hasInitialLoadFired.value) return
if (!state.ui.diff.result.value) return
diffExtension.updateVisualDiff(state.ui.diff.time.value, val)
})
useOnViewerLoadComplete(({ isInitial }) => {
if (!isInitial) return
hasInitialLoadFired.value = true
triggerDiffCommandWatch()
})
}
function useDisableZoomOnEmbed() {
const { viewer } = useInjectedViewerState()
const embedOptions = useEmbed()
watch(
() => embedOptions.noScroll.value,
(newNoScrollValue) => {
const cameraController: CameraController =
viewer.instance.getExtension(CameraController)
if (newNoScrollValue) {
cameraController.options = { enableZoom: false }
} else {
cameraController.options = { enableZoom: true }
}
},
{ immediate: true }
)
}
function useViewerTreeIntegration() {
const { viewer } = useInjectedViewerState()
const { treeStateManager } = useTreeManagement()
// Initialize the tree state manager with viewer instance
onMounted(() => treeStateManager.initialize(viewer.instance))
}
graphql(`
fragment UseViewerSavedViewSetup_SavedView on SavedView {
id
viewerState
...ViewerPageSetup_SavedView
}
`)
function useViewerCursorIntegration() {
const {
viewer: { container }
} = useInjectedViewerState()
const {
filters: { selectedObjects }
} = useInjectedViewerInterfaceState()
const { shift } = useMagicKeys()
const isDragging = ref(false)
// Handle mouse down/up to track dragging state
const handlePointerDown = (_event: PointerEvent) => {
if (shift.value && selectedObjects.value.length === 0) {
isDragging.value = true
}
}
const handlePointerUp = () => {
isDragging.value = false
}
// Show different cursors: grab (ready to drag) vs grabbing (actively dragging)
watch(
[shift, selectedObjects, isDragging],
() => {
if (!container) return
const hasSelection = selectedObjects.value.length > 0
const shouldShowDrag = shift.value && !hasSelection
if (shouldShowDrag) {
container.style.cursor = isDragging.value ? 'grabbing' : 'grab'
} else {
container.style.cursor = ''
}
},
{ immediate: true }
)
useEventListener(container, 'pointerdown', handlePointerDown, { passive: true })
useEventListener(document, 'pointerup', handlePointerUp, { passive: true })
onBeforeUnmount(() => {
if (container) {
container.style.cursor = ''
}
})
}
const useCommentContextIntegration = () => {
const { cleanupThreadContext } = useCommentContext()
onBeforeUnmount(() => {
cleanupThreadContext()
})
}
export function useViewerPostSetup() {
if (import.meta.server) return
useViewerObjectAutoLoading()
useViewerSavedViewIntegration()
useViewerReceiveTracking()
useViewerSelectionEventHandler()
useViewerLoadCompleteEventHandler()
useViewerSubscriptionEventTracker()
useViewerThreadTracking()
useViewerOpenedThreadUpdateEmitter()
useViewerSectionBoxIntegration()
useViewerCameraIntegration()
useViewerFiltersIntegration()
useLightConfigIntegration()
useExplodeFactorIntegration()
useDiffingIntegration()
useMeasurementsPostSetup()
useFilterColoringPostSetup()
usePropertyFilteringPostSetup()
useManualFilteringPostSetup()
useDisableZoomOnEmbed()
useViewerCursorIntegration()
useViewerTreeIntegration()
useViewModesPostSetup()
useHighlightingPostSetup()
useCommentContextIntegration()
setupDebugMode()
}