1591 lines
47 KiB
TypeScript
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()
|
|
}
|