613614d5d0
* Edge drawing render pass using both depth and normal data * Edges are now mixed with the rest of the passes in the final pass * Set MSAA for SS edges framebuffers to maximum number of samples * New depth gradient extraction approach. Several improvements over the old one * Some changes to the normal gradient extraction. Using Robert's Cross works roughly the same as the older approach with 1 less texture fetch. Also implemented Canny * Combined static AO with edges * Implemented SMAA and static TAA. Both toggeable in realtime for better comparisons * Added the normal pass and fixed some issues * The pipeline is now capable of jitter individual passes if requested * Added TAA pass. Technically it can be used to antialias anything. Right now we've disabled MSAA and using TAA for stationary as an example * Added a TAA pipeline on the final image. Added DPR into pipeline resize calls. Struggled a *lot* with different blending results between drawing to the backbuffer and to a framebuffer then copying to the backbuffer. There are still some differences visible * Edges pipeline * Edges + TAA + AO + COLOR pipeline * ShadedView mode * Visibility is now properly reset when coming from a visibility conditioned pass to an unconditioned one * Added another version for 'shaded' view, inspired by blender's viewport shading * Added pen view together with a sample paper like texture for vissual background * A form of techincal view * Arctic view. Separate viewport pass from matcap pass * Cleaned up the ao shader a bit. Formalized progressive ao pass options * WIP on BasitMode * Added pipeline switching to the sandbox. Edge pass now has an optional texture background * Implemented BasicMode using material overrides * Merged with latest main * Fix merge issues * More merge misses * Stating stream * Fixed an issue that took hours to debug and it turned out to be a literal typo * Formalized the progressive pipeline concept which removed a lot of duplicated code * Added pipeline reseting. Added pipeline passthrough to allow features like measurements to run correctly. Added jitter to depth buffer for progressive AO for much better results! * Implemented the required stencil passes used for stencil selection as new pass formats. Added them as well as the overlay pass to all pipelines. Will need to automate their addition though * Fixed stencil selection in pen view. Decided to only show outlines because it makes more sense * Implemented proper pass clearing values and flags. Also made sure the minimum amount of clears are made inside the frame. Fixed an issue with progressive pass frame counts and indices. * Formalized options for all passes that had any. Tidied up some shaders and simplified where possible * Centralized render target creation and removed a lot of render target creation in the passes that had no need for it. Fixed an issue where the matcap material would not not update it's matcap texture properly when rendering in override mode * Matcap and Viewport passes where merged into a single Viewport pass that can take a matcap texture in it's options. Also, changed the way the viewport shader displays colors, based on three's textureless matcap shader block. * Updated PassReader extension to work with the latest pipeline. NNowthe pass reader accepts the pass name when reading, so it can used to read any passes * WIP on MRT * Implemented DepthNormal MRT pass and used in a pipeline. Working fine on WebGL2.0 * Full Support for WebGL1.0 * Fix for viewport material not updating it's texture properly * Added an extension that enables hotkeys for view modes * Fixed jittering for orthographic projections * Fixed the section tool integration issues with the new pipelines * Added MRT versions of the pipelines that need it] * Added a wrapper ViewModes extensions that defines the existing view modes and handles the required check for MRT capabilities and selects the nonMRT/MRT pipelines accordingly * Error fixes * Another error * Added shadowcatcher to arctic and shaded view since it was missing by mistake. Increased the KERNEL_SIZE for arctic mode so that the AO is more smooth * Disabled pen view background texture * When reseting the pipeline the jitter index is also reset in order to avoid visible jittering when switching fast between dynamic and progressive mode repeteadly (like when moving the clipping box) * Quick solution for integrating view modes with per-object operatins like selection and filtering * View modes now have the ability of overriding the batch's default material instead of all it;s materials. This means that per object material changes like selection and filtering will work together with view modes. Handles WEB-2080, WEB-2083, WEB-2084, WEB-2081 and WEB-2082 * Fiter applies now trigger a hard reset, which is required and fixes a preexisting bug in production. Fixed an issue where the shadowcatcher would incorrectly blend in shaded view mode * Viewport pass can now render transparency. Arctic mode now renders transparent objects as well (was originally required) * Forcing the multipel render targets we use for MRT to use a 32 bit depth buffer for proper precision on MacOs Chromium * Fixed an issue where incorrect visible ranges were being perpetuated when selecting hidden objects (outlines only mode) * PROPS are not rendered twice anymore and they've been moved to the overlay pass in all pipelines * Reorganized passes and pipelines. Deleted old passes * Renamed all new passes. Only the interface and base pass types have a G prepended to make it 100% clear it's different than three's Pass type * Fixed some compiling errors * Dont change viewmodes when inputs are being used * Pipeline gets resized whenever it's set in the renderer. Some other smaller changes --------- Co-authored-by: Mike Tasset <mike.tasset@gmail.com>
1097 lines
33 KiB
TypeScript
1097 lines
33 KiB
TypeScript
import {
|
|
DefaultViewerParams,
|
|
ViewerEvent,
|
|
DefaultLightConfiguration,
|
|
LegacyViewer,
|
|
MeasurementType,
|
|
FilteringExtension
|
|
} from '@speckle/viewer'
|
|
import type {
|
|
FilteringState,
|
|
PropertyInfo,
|
|
SunLightConfiguration,
|
|
SpeckleView,
|
|
MeasurementOptions,
|
|
DiffResult,
|
|
Viewer,
|
|
WorldTree,
|
|
VisualDiffMode
|
|
} from '@speckle/viewer'
|
|
import type { MaybeRef } from '@vueuse/shared'
|
|
import { inject, ref, provide } from 'vue'
|
|
import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue'
|
|
import { useScopedState } from '~~/lib/common/composables/scopedState'
|
|
import type { MaybeNullOrUndefined, Nullable, Optional } from '@speckle/shared'
|
|
import { SpeckleViewer, isNonNullable } from '@speckle/shared'
|
|
import { useApolloClient, useLazyQuery, useQuery } from '@vue/apollo-composable'
|
|
import {
|
|
projectViewerResourcesQuery,
|
|
viewerLoadedResourcesQuery,
|
|
viewerLoadedThreadsQuery,
|
|
viewerModelVersionsQuery
|
|
} from '~~/lib/viewer/graphql/queries'
|
|
import type {
|
|
ProjectViewerResourcesQueryVariables,
|
|
ViewerLoadedResourcesQuery,
|
|
ViewerLoadedResourcesQueryVariables,
|
|
ViewerLoadedThreadsQuery,
|
|
ViewerResourceItem,
|
|
ViewerLoadedThreadsQueryVariables,
|
|
ProjectCommentsFilter,
|
|
ViewerModelVersionCardItemFragment
|
|
} from '~~/lib/common/generated/gql/graphql'
|
|
import type { SetNonNullable, Get } from 'type-fest'
|
|
import {
|
|
convertThrowIntoFetchResult,
|
|
getFirstErrorMessage
|
|
} from '~~/lib/common/helpers/graphql'
|
|
import { nanoid } from 'nanoid'
|
|
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
|
import type { CommentBubbleModel } from '~~/lib/viewer/composables/commentBubbles'
|
|
import { setupUrlHashState } from '~~/lib/viewer/composables/setup/urlHashState'
|
|
import type { SpeckleObject } from '~/lib/viewer/helpers/sceneExplorer'
|
|
import type { Box3 } from 'three'
|
|
import { Vector3 } from 'three'
|
|
import { writableAsyncComputed } from '~~/lib/common/composables/async'
|
|
import type { AsyncWritableComputedRef } from '~~/lib/common/composables/async'
|
|
import { setupUiDiffState } from '~~/lib/viewer/composables/setup/diff'
|
|
import type { DiffStateCommand } from '~~/lib/viewer/composables/setup/diff'
|
|
import { useDiffUtilities, useFilterUtilities } from '~~/lib/viewer/composables/ui'
|
|
import { flatten, reduce } from 'lodash-es'
|
|
import { setupViewerCommentBubbles } from '~~/lib/viewer/composables/setup/comments'
|
|
import {
|
|
InjectableViewerStateKey,
|
|
useSetupViewerScope
|
|
} from '~/lib/viewer/composables/setup/core'
|
|
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
|
|
import { buildManualPromise } from '@speckle/ui-components'
|
|
import { PassReader } from '../extensions/PassReader'
|
|
import { ViewModesKeys } from '../extensions/ViewModesKeys'
|
|
|
|
export type LoadedModel = NonNullable<
|
|
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
|
|
>
|
|
|
|
export type LoadedThreadsMetadata = NonNullable<
|
|
Get<ViewerLoadedThreadsQuery, 'project.commentThreads'>
|
|
>
|
|
|
|
export type LoadedCommentThread = NonNullable<Get<LoadedThreadsMetadata, 'items[0]'>>
|
|
|
|
export type InjectableViewerState = Readonly<{
|
|
/**
|
|
* The project which we're opening in the viewer (all loaded models should belong to it)
|
|
*/
|
|
projectId: ComputedRef<string>
|
|
/**
|
|
* User viewer session ID. The same user will have different IDs in different tabs if multiple are open.
|
|
* This is used to ignore user activity messages from the same tab.
|
|
*/
|
|
sessionId: ComputedRef<string>
|
|
/**
|
|
* The actual Viewer instance and related objects.
|
|
* Note: This is going to be undefined in SSR!
|
|
*/
|
|
viewer: {
|
|
/**
|
|
* The actual viewer instance
|
|
*/
|
|
instance: LegacyViewer
|
|
/**
|
|
* Container onto which the Viewer instance is attached
|
|
*/
|
|
container: HTMLElement
|
|
/**
|
|
* For checking when viewer.init() is complete
|
|
*/
|
|
init: {
|
|
promise: Promise<void>
|
|
ref: ComputedRef<boolean>
|
|
}
|
|
/**
|
|
* Various values that represent the current Viewer instance state
|
|
*/
|
|
metadata: {
|
|
/**
|
|
* Based on a shallow ref
|
|
*/
|
|
worldTree: ComputedRef<Optional<WorldTree>>
|
|
availableFilters: ComputedRef<Optional<PropertyInfo[]>>
|
|
views: ComputedRef<SpeckleView[]>
|
|
filteringState: ComputedRef<Optional<FilteringState>>
|
|
}
|
|
/**
|
|
* Whether the Viewer has finished doing the initial object loading
|
|
*/
|
|
hasDoneInitialLoad: Ref<boolean>
|
|
}
|
|
/**
|
|
* Loaded/loadable resources
|
|
*/
|
|
resources: {
|
|
/**
|
|
* State of resource identifiers that should be loaded (tied to the URL param)
|
|
*/
|
|
request: {
|
|
/**
|
|
* All currently requested identifiers. You
|
|
* can write to this to change which resources should be loaded.
|
|
*/
|
|
items: AsyncWritableComputedRef<SpeckleViewer.ViewerRoute.ViewerResource[]>
|
|
/**
|
|
* All currently requested identifiers in a comma-delimited string, the way it's
|
|
* represented in the URL. Is writable also.
|
|
*/
|
|
resourceIdString: AsyncWritableComputedRef<string>
|
|
|
|
/**
|
|
* Writable computed for reading/writing current thread filters
|
|
*/
|
|
threadFilters: Ref<Omit<ProjectCommentsFilter, 'resourceIdString'>>
|
|
|
|
/**
|
|
* Helper for switching model to a specific version (or just latest)
|
|
*/
|
|
switchModelToVersion: (modelId: string, versionId?: string) => Promise<void>
|
|
// addModelVersion: (modelId: string, versionId: string) => void
|
|
// removeModelVersion: (modelId: string, versionId?: string) => void
|
|
// setModelVersions: (newResources: ViewerResource[]) => void
|
|
}
|
|
/**
|
|
* State of resolved, validated & de-duplicated resources that are loaded in the viewer. These
|
|
* are resolved from multiple GQL requests and update whenever resources.request updates.
|
|
*/
|
|
response: {
|
|
/**
|
|
* Metadata about loaded items
|
|
*/
|
|
resourceItems: ComputedRef<ViewerResourceItem[]>
|
|
/**
|
|
* Variables used to load resource items identified by URL identifiers. Relevant when making cache updates
|
|
*/
|
|
resourceItemsQueryVariables: ComputedRef<
|
|
Optional<ProjectViewerResourcesQueryVariables>
|
|
>
|
|
/**
|
|
* Whether or not the initial resource items load has happened (useful in SSR)
|
|
*/
|
|
resourceItemsLoaded: ComputedRef<boolean>
|
|
/**
|
|
* Whether or not the initial resources (models, objects etc.) have been loaded (useful in SSR)
|
|
*/
|
|
resourcesLoaded: ComputedRef<boolean>
|
|
/**
|
|
* Model GQL objects paired with their loaded version IDs
|
|
*/
|
|
modelsAndVersionIds: ComputedRef<Array<{ model: LoadedModel; versionId: string }>>
|
|
/**
|
|
* All available (retrieved from GQL) models and their versions
|
|
*/
|
|
availableModelsAndVersions: ComputedRef<
|
|
Array<{ model: LoadedModel; versions: LoadedModel['versions']['items'] }>
|
|
>
|
|
/**
|
|
* Detached objects (not models/versions)
|
|
*/
|
|
objects: ComputedRef<ViewerResourceItem[]>
|
|
/**
|
|
* Comment threads for all loaded resources
|
|
*/
|
|
commentThreads: ComputedRef<Array<LoadedCommentThread>>
|
|
/**
|
|
* Metadata about requested comment threads (e.g. total counts)
|
|
*/
|
|
commentThreadsMetadata: ComputedRef<Optional<LoadedThreadsMetadata>>
|
|
/**
|
|
* Project main metadata
|
|
*/
|
|
project: ComputedRef<Optional<Get<ViewerLoadedResourcesQuery, 'project'>>>
|
|
/**
|
|
* Variables used to load the resource query. Relevant when making cache updates.
|
|
*/
|
|
resourceQueryVariables: ComputedRef<Optional<ViewerLoadedResourcesQueryVariables>>
|
|
/**
|
|
* Variables used to load the threads query. Relevant when making cache updates.
|
|
*/
|
|
threadsQueryVariables: ComputedRef<Optional<ViewerLoadedThreadsQueryVariables>>
|
|
/**
|
|
* Fetch the next page of versions for a loaded model
|
|
*/
|
|
loadMoreVersions: (modelId: string) => Promise<void>
|
|
}
|
|
}
|
|
/**
|
|
* Interface state
|
|
*/
|
|
ui: {
|
|
/**
|
|
* Thread and their bubble state
|
|
*/
|
|
threads: {
|
|
/**
|
|
* Comment bubble models keyed by comment ID
|
|
*/
|
|
items: Ref<Record<string, CommentBubbleModel>>
|
|
openThread: {
|
|
thread: ComputedRef<Optional<CommentBubbleModel>>
|
|
isTyping: Ref<boolean>
|
|
newThreadEditor: Ref<boolean>
|
|
}
|
|
hideBubbles: Ref<boolean>
|
|
}
|
|
spotlightUserSessionId: Ref<Nullable<string>>
|
|
filters: {
|
|
isolatedObjectIds: Ref<string[]>
|
|
hiddenObjectIds: Ref<string[]>
|
|
selectedObjects: Ref<Raw<SpeckleObject>[]>
|
|
/**
|
|
* For quick object ID lookups
|
|
*/
|
|
selectedObjectIds: ComputedRef<Set<string>>
|
|
propertyFilter: {
|
|
filter: Ref<Nullable<PropertyInfo>>
|
|
isApplied: Ref<boolean>
|
|
}
|
|
hasAnyFiltersApplied: ComputedRef<boolean>
|
|
}
|
|
camera: {
|
|
position: Ref<Vector3>
|
|
target: Ref<Vector3>
|
|
isOrthoProjection: Ref<boolean>
|
|
}
|
|
diff: {
|
|
newVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
|
|
oldVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
|
|
time: Ref<number>
|
|
mode: Ref<VisualDiffMode>
|
|
result: ShallowRef<Optional<DiffResult>> //ComputedRef<Optional<DiffResult>>
|
|
enabled: Ref<boolean>
|
|
}
|
|
sectionBox: Ref<Nullable<Box3>>
|
|
sectionBoxContext: {
|
|
visible: Ref<boolean>
|
|
edited: Ref<boolean>
|
|
}
|
|
highlightedObjectIds: Ref<string[]>
|
|
lightConfig: Ref<SunLightConfiguration>
|
|
explodeFactor: Ref<number>
|
|
viewerBusy: WritableComputedRef<boolean>
|
|
selection: Ref<Nullable<Vector3>>
|
|
measurement: {
|
|
enabled: Ref<boolean>
|
|
options: Ref<MeasurementOptions>
|
|
}
|
|
}
|
|
/**
|
|
* State stored in the anchor string of the URL
|
|
*/
|
|
urlHashState: {
|
|
focusedThreadId: AsyncWritableComputedRef<Nullable<string>>
|
|
diff: AsyncWritableComputedRef<Nullable<DiffStateCommand>>
|
|
}
|
|
}>
|
|
|
|
type CachedViewerState = Pick<
|
|
InjectableViewerState['viewer'],
|
|
'container' | 'instance'
|
|
> & {
|
|
initPromise: Promise<void>
|
|
}
|
|
|
|
type InitialSetupState = Pick<
|
|
InjectableViewerState,
|
|
'projectId' | 'viewer' | 'sessionId' | 'urlHashState'
|
|
>
|
|
|
|
type InitialStateWithRequest = InitialSetupState & {
|
|
resources: { request: InjectableViewerState['resources']['request'] }
|
|
}
|
|
|
|
export type InitialStateWithRequestAndResponse = InitialSetupState &
|
|
Pick<InjectableViewerState, 'resources'>
|
|
|
|
export type InitialStateWithUrlHashState = InitialStateWithRequestAndResponse
|
|
|
|
export type InitialStateWithInterface = InitialStateWithUrlHashState &
|
|
Pick<InjectableViewerState, 'ui'>
|
|
|
|
/**
|
|
* Scoped state key for 'viewer' metadata, as we reuse it between routes
|
|
*/
|
|
const GlobalViewerDataKey = Symbol('GlobalViewerData')
|
|
|
|
function createViewerDataBuilder(params: { viewerDebug: boolean }) {
|
|
return () => {
|
|
if (import.meta.server)
|
|
// we don't want to use nullable checks everywhere, so the nicer route here ends
|
|
// up being telling TS to ignore the undefineds - you shouldn't use any of this in SSR anyway
|
|
return undefined as unknown as CachedViewerState
|
|
|
|
const container = document.createElement('div')
|
|
container.id = 'renderer'
|
|
container.style.display = 'block'
|
|
container.style.width = '100%'
|
|
container.style.height = '100%'
|
|
|
|
const viewer = new LegacyViewer(container, {
|
|
...DefaultViewerParams,
|
|
verbose: !!(import.meta.client && params.viewerDebug)
|
|
})
|
|
viewer.createExtension(PassReader)
|
|
viewer.createExtension(ViewModesKeys)
|
|
const initPromise = viewer.init()
|
|
|
|
return {
|
|
instance: viewer,
|
|
container,
|
|
initPromise
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupViewerMetadata(params: {
|
|
viewer: Viewer
|
|
}): InitialSetupState['viewer']['metadata'] {
|
|
const { viewer } = params
|
|
|
|
const worldTree = shallowRef(undefined as Optional<WorldTree>)
|
|
const availableFilters = shallowRef(undefined as Optional<PropertyInfo[]>)
|
|
const filteringState = shallowRef(undefined as Optional<FilteringState>)
|
|
const views = ref([] as SpeckleView[])
|
|
|
|
const refreshWorldTreeAndFilters = async (busy: boolean) => {
|
|
if (busy) return
|
|
worldTree.value = viewer.getWorldTree()
|
|
availableFilters.value = await viewer.getObjectProperties()
|
|
views.value = viewer.getViews()
|
|
}
|
|
const updateFilteringState = (newState: FilteringState) => {
|
|
filteringState.value = newState
|
|
}
|
|
|
|
onMounted(() => {
|
|
viewer.on(ViewerEvent.Busy, refreshWorldTreeAndFilters)
|
|
viewer
|
|
.getExtension(FilteringExtension)
|
|
.on(ViewerEvent.FilteringStateSet, updateFilteringState)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
viewer.removeListener(ViewerEvent.Busy, refreshWorldTreeAndFilters)
|
|
viewer
|
|
.getExtension(FilteringExtension)
|
|
.removeListener(ViewerEvent.FilteringStateSet, updateFilteringState)
|
|
})
|
|
|
|
return {
|
|
worldTree: computed(() => worldTree.value),
|
|
availableFilters: computed(() => availableFilters.value),
|
|
filteringState: computed(() => filteringState.value),
|
|
views: computed(() => views.value)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup actual viewer instance & related data
|
|
*/
|
|
function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
|
|
const {
|
|
public: { viewerDebug }
|
|
} = useRuntimeConfig()
|
|
|
|
const projectId = computed(() => unref(params.projectId))
|
|
|
|
const sessionId = computed(() => nanoid())
|
|
const isInitialized = ref(false)
|
|
const { instance, initPromise, container } = useScopedState(
|
|
GlobalViewerDataKey,
|
|
createViewerDataBuilder({ viewerDebug })
|
|
) || { initPromise: Promise.resolve() }
|
|
initPromise.then(() => (isInitialized.value = true))
|
|
const hasDoneInitialLoad = ref(false)
|
|
|
|
return {
|
|
projectId,
|
|
sessionId,
|
|
viewer: import.meta.server
|
|
? ({
|
|
instance: undefined,
|
|
container: undefined,
|
|
init: {
|
|
promise: new Promise(() => {}),
|
|
ref: computed(() => false)
|
|
},
|
|
metadata: {
|
|
worldTree: computed(() => undefined),
|
|
availableFilters: computed(() => undefined),
|
|
views: computed(() => []),
|
|
filteringState: computed(() => undefined)
|
|
},
|
|
hasDoneInitialLoad
|
|
} as unknown as InitialSetupState['viewer'])
|
|
: {
|
|
instance,
|
|
container,
|
|
init: {
|
|
promise: initPromise,
|
|
ref: computed(() => isInitialized.value)
|
|
},
|
|
metadata: setupViewerMetadata({ viewer: instance }),
|
|
hasDoneInitialLoad
|
|
},
|
|
urlHashState: setupUrlHashState()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup resource requests (tied to URL resource identifier param)
|
|
*/
|
|
function setupResourceRequest(state: InitialSetupState): InitialStateWithRequest {
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const getParam = computed(() => route.params.modelId as string)
|
|
|
|
const resources = writableAsyncComputed({
|
|
get: () => SpeckleViewer.ViewerRoute.parseUrlParameters(getParam.value),
|
|
set: async (newResources) => {
|
|
const modelId =
|
|
SpeckleViewer.ViewerRoute.createGetParamFromResources(newResources)
|
|
await router.push({
|
|
params: { modelId },
|
|
query: route.query,
|
|
hash: route.hash
|
|
})
|
|
},
|
|
initialState: [],
|
|
asyncRead: false
|
|
})
|
|
|
|
// we could use getParam, but `createGetParamFromResources` does sorting and de-duplication AFAIK
|
|
const resourceIdString = writableAsyncComputed({
|
|
get: () => SpeckleViewer.ViewerRoute.createGetParamFromResources(resources.value),
|
|
set: async (newVal) => {
|
|
const newResources = SpeckleViewer.ViewerRoute.parseUrlParameters(newVal)
|
|
await resources.update(newResources)
|
|
},
|
|
initialState: '',
|
|
asyncRead: false
|
|
})
|
|
|
|
const discussionLoadedVersionOnly = useSynchronizedCookie<boolean>(
|
|
'discussionLoadedVersionOnly',
|
|
{
|
|
default: () => true
|
|
}
|
|
)
|
|
|
|
const threadFilters = ref({ loadedVersionsOnly: discussionLoadedVersionOnly.value })
|
|
|
|
const switchModelToVersion = async (modelId: string, versionId?: string) => {
|
|
const resourceArr = resources.value.slice()
|
|
|
|
const resourceIdx = resourceArr.findIndex(
|
|
(r) => SpeckleViewer.ViewerRoute.isModelResource(r) && r.modelId === modelId
|
|
)
|
|
|
|
if (resourceIdx !== -1) {
|
|
// Replace
|
|
const newResources = resources.value.slice()
|
|
newResources.splice(
|
|
resourceIdx,
|
|
1,
|
|
new SpeckleViewer.ViewerRoute.ViewerModelResource(modelId, versionId)
|
|
)
|
|
|
|
await resources.update(newResources)
|
|
} else {
|
|
// Add new one and allow de-duplication to do its thing
|
|
await resources.update([
|
|
new SpeckleViewer.ViewerRoute.ViewerModelResource(modelId, versionId),
|
|
...resources.value
|
|
])
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => threadFilters.value.loadedVersionsOnly,
|
|
(newVal, oldVal) => {
|
|
if (newVal !== oldVal && newVal !== discussionLoadedVersionOnly.value) {
|
|
discussionLoadedVersionOnly.value = newVal
|
|
}
|
|
}
|
|
)
|
|
|
|
return {
|
|
...state,
|
|
resources: {
|
|
request: {
|
|
items: resources,
|
|
resourceIdString,
|
|
threadFilters,
|
|
switchModelToVersion
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse URL resource request and figure out the actual resource items we need to load in the viewer
|
|
* through the GQL API
|
|
*/
|
|
function setupResponseResourceItems(
|
|
state: InitialStateWithRequest
|
|
): Pick<
|
|
InjectableViewerState['resources']['response'],
|
|
'resourceItems' | 'resourceItemsQueryVariables' | 'resourceItemsLoaded'
|
|
> {
|
|
const globalError = useError()
|
|
const {
|
|
projectId,
|
|
resources: {
|
|
request: { resourceIdString }
|
|
}
|
|
} = state
|
|
|
|
const initLoadDone = ref(import.meta.server ? false : true)
|
|
const {
|
|
result: resolvedResourcesResult,
|
|
variables: resourceItemsQueryVariables,
|
|
onError,
|
|
onResult
|
|
} = useQuery(
|
|
projectViewerResourcesQuery,
|
|
() => ({
|
|
projectId: projectId.value,
|
|
resourceUrlString: resourceIdString.value
|
|
}),
|
|
{ keepPreviousResult: true }
|
|
)
|
|
|
|
onError((err) => {
|
|
globalError.value = createError({
|
|
statusCode: 500,
|
|
message: `Viewer resource resolution failed: ${err}`
|
|
})
|
|
initLoadDone.value = true
|
|
})
|
|
|
|
onResult(() => {
|
|
initLoadDone.value = true
|
|
})
|
|
|
|
const resolvedResourceGroups = computed(
|
|
() => resolvedResourcesResult.value?.project?.viewerResources || []
|
|
)
|
|
|
|
/**
|
|
* Validated & de-duplicated resources that should be loaded in the viewer
|
|
*/
|
|
const resourceItems = computed(() => {
|
|
/**
|
|
* Flatten results into an array of items that are properly ordered according to resource identifier priority.
|
|
* Loading priority: Model w/ version > Model > Folder name > Object ID
|
|
*/
|
|
const versionItems: ViewerResourceItem[] = []
|
|
const modelItems: ViewerResourceItem[] = []
|
|
const folderItems: ViewerResourceItem[] = []
|
|
const objectItems: ViewerResourceItem[] = []
|
|
const allModelItems: ViewerResourceItem[] = []
|
|
for (const group of resolvedResourceGroups.value) {
|
|
const [resource] = SpeckleViewer.ViewerRoute.parseUrlParameters(group.identifier)
|
|
|
|
for (const item of group.items) {
|
|
if (SpeckleViewer.ViewerRoute.isModelResource(resource)) {
|
|
if (resource.versionId) {
|
|
versionItems.push(item)
|
|
} else {
|
|
modelItems.push(item)
|
|
}
|
|
} else if (SpeckleViewer.ViewerRoute.isAllModelsResource(resource)) {
|
|
allModelItems.push(item)
|
|
} else if (SpeckleViewer.ViewerRoute.isModelFolderResource(resource)) {
|
|
folderItems.push(item)
|
|
} else if (SpeckleViewer.ViewerRoute.isObjectResource(resource)) {
|
|
objectItems.push(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
const orderedItems = [
|
|
...versionItems,
|
|
...modelItems,
|
|
...folderItems,
|
|
...allModelItems,
|
|
...objectItems
|
|
]
|
|
|
|
// Get rid of duplicates - only 1 resource per objectId
|
|
const encounteredModels = new Set<string>()
|
|
const encounteredObjects = new Set<string>()
|
|
const finalItems: ViewerResourceItem[] = []
|
|
for (const item of orderedItems) {
|
|
const modelId = item.modelId
|
|
const objectId = item.objectId
|
|
|
|
// Uncommenting the following line resolved model duplication issues in the Model Panel
|
|
// without affecting diffing functionality. If future diffing problems arise, revisit this.
|
|
if (modelId && encounteredModels.has(modelId)) continue
|
|
if (encounteredObjects.has(objectId)) continue
|
|
|
|
finalItems.push(item)
|
|
if (modelId) encounteredModels.add(modelId)
|
|
encounteredObjects.add(objectId)
|
|
}
|
|
|
|
return finalItems
|
|
})
|
|
|
|
const resourceItemsLoaded = computed(() => initLoadDone.value)
|
|
|
|
return {
|
|
resourceItems,
|
|
resourceItemsQueryVariables: computed(() => resourceItemsQueryVariables.value),
|
|
resourceItemsLoaded
|
|
}
|
|
}
|
|
|
|
function setupResponseResourceData(
|
|
state: InitialStateWithRequest,
|
|
resourceItemsData: ReturnType<typeof setupResponseResourceItems>
|
|
): Omit<
|
|
InjectableViewerState['resources']['response'],
|
|
'resourceItems' | 'resourceItemsQueryVariables' | 'resourceItemsLoaded'
|
|
> {
|
|
const apollo = useApolloClient().client
|
|
const globalError = useError()
|
|
const { triggerNotification } = useGlobalToast()
|
|
const logger = useLogger()
|
|
|
|
const {
|
|
projectId,
|
|
resources: {
|
|
request: { resourceIdString, threadFilters }
|
|
},
|
|
urlHashState: { diff }
|
|
} = state
|
|
const { resourceItems, resourceItemsLoaded } = resourceItemsData
|
|
|
|
const initLoadDone = ref(import.meta.server ? false : true)
|
|
const objects = computed(() =>
|
|
resourceItems.value.filter((i) => !i.modelId && !i.versionId)
|
|
)
|
|
|
|
const nonObjectResourceItems = computed(() =>
|
|
resourceItems.value.filter(
|
|
(r): r is ViewerResourceItem & { modelId: string; versionId: string } =>
|
|
!!r.modelId
|
|
)
|
|
)
|
|
|
|
const diffVersionIds = computed(() =>
|
|
flatten(
|
|
(diff.value?.diffs || []).map((d) => [d.versionA.versionId, d.versionB.versionId])
|
|
)
|
|
)
|
|
|
|
// model.loadedVersion will be the actually currently loaded version +
|
|
// any diff versions, if they're requested. the naming is confusing, but
|
|
// model.loadedVersion = all currently loaded versions of that model, altho there's usually only 1
|
|
const versionIds = computed(() =>
|
|
[
|
|
...nonObjectResourceItems.value.map((r) => r.versionId),
|
|
...diffVersionIds.value
|
|
].sort()
|
|
)
|
|
const versionCursors = ref({} as Record<string, Nullable<string>>)
|
|
|
|
const viewerLoadedResourcesVariablesFunc =
|
|
(): ViewerLoadedResourcesQueryVariables => ({
|
|
projectId: projectId.value,
|
|
modelIds: nonObjectResourceItems.value.map((r) => r.modelId).sort(),
|
|
versionIds: versionIds.value
|
|
})
|
|
|
|
// MODELS AND VERSIONS
|
|
// sorting variables so that we don't refetech just because the order changed
|
|
const {
|
|
result: viewerLoadedResourcesResult,
|
|
variables: viewerLoadedResourcesVariables,
|
|
onError: onViewerLoadedResourcesError,
|
|
onResult: onViewerLoadedResourcesResult,
|
|
load: loadViewerLoadedResources
|
|
} = useLazyQuery(viewerLoadedResourcesQuery, viewerLoadedResourcesVariablesFunc, {
|
|
keepPreviousResult: true
|
|
})
|
|
|
|
const serverResourcesLoadedPromise = buildManualPromise<void>()
|
|
if (import.meta.server) {
|
|
watch(
|
|
() => resourceItemsLoaded.value,
|
|
async (newVal, oldVal) => {
|
|
if (!newVal || oldVal) return
|
|
|
|
// Load only now - once the previous query is done
|
|
await loadViewerLoadedResources()
|
|
serverResourcesLoadedPromise.resolve()
|
|
},
|
|
{ flush: 'sync' }
|
|
)
|
|
} else {
|
|
loadViewerLoadedResources()
|
|
serverResourcesLoadedPromise.resolve()
|
|
}
|
|
|
|
const project = computed(() => viewerLoadedResourcesResult.value?.project)
|
|
const models = computed(() => project.value?.models?.items || [])
|
|
|
|
const modelsAndVersionIds = computed(() =>
|
|
nonObjectResourceItems.value
|
|
.map((r) => ({
|
|
versionId: r.versionId,
|
|
model: models.value.find((m) => m.id === r.modelId)
|
|
}))
|
|
.filter((o): o is SetNonNullable<typeof o, 'model'> => !!(o.versionId && o.model))
|
|
)
|
|
|
|
const availableModelsAndVersions = computed(() => {
|
|
const modelItems = models.value
|
|
return reduce(
|
|
modelItems,
|
|
(res, entry) => {
|
|
res.push({
|
|
model: entry,
|
|
versions: [...entry.loadedVersion.items, ...entry.versions.items]
|
|
})
|
|
return res
|
|
},
|
|
[] as Array<{
|
|
model: (typeof modelItems)[0]
|
|
versions: (typeof modelItems)[0]['versions']['items']
|
|
}>
|
|
)
|
|
})
|
|
|
|
onViewerLoadedResourcesError((err) => {
|
|
// Show full page error only if serious error (core data couldn't be loaded)
|
|
const isWorkingLoad = !!viewerLoadedResourcesResult.value?.project.models.items
|
|
if (isWorkingLoad) {
|
|
return
|
|
}
|
|
|
|
globalError.value = createError({
|
|
statusCode: 500,
|
|
message: `Viewer loaded resource resolution failed: ${err}`
|
|
})
|
|
initLoadDone.value = true
|
|
})
|
|
|
|
// Load initial batch of cursors for each model
|
|
onViewerLoadedResourcesResult((res) => {
|
|
initLoadDone.value = true
|
|
if (!res.data?.project?.models) return
|
|
|
|
for (const model of res.data.project.models.items) {
|
|
const modelId = model.id
|
|
if (versionCursors.value[modelId]) continue
|
|
|
|
const cursor = model.versions.cursor
|
|
if (!cursor) continue
|
|
|
|
versionCursors.value[modelId] = cursor
|
|
}
|
|
})
|
|
|
|
const loadMoreVersions = async (modelId: string) => {
|
|
const cursor = versionCursors.value[modelId]
|
|
const baseVariables = viewerLoadedResourcesVariablesFunc()
|
|
const { data, errors } = await apollo
|
|
.query({
|
|
query: viewerModelVersionsQuery,
|
|
variables: {
|
|
projectId: baseVariables.projectId,
|
|
modelId,
|
|
versionsCursor: cursor
|
|
},
|
|
fetchPolicy: 'network-only'
|
|
})
|
|
.catch(convertThrowIntoFetchResult)
|
|
|
|
if (!data?.project?.model?.versions) {
|
|
triggerNotification({
|
|
type: ToastNotificationType.Danger,
|
|
title: "Can't load more versions",
|
|
description: getFirstErrorMessage(errors)
|
|
})
|
|
return
|
|
}
|
|
|
|
if (data.project.model.versions.cursor) {
|
|
versionCursors.value[modelId] = data.project.model.versions.cursor
|
|
}
|
|
}
|
|
|
|
// COMMENT THREADS
|
|
const {
|
|
result: viewerLoadedThreadsResult,
|
|
onError: onViewerLoadedThreadsError,
|
|
variables: threadsQueryVariables
|
|
} = useQuery(
|
|
viewerLoadedThreadsQuery,
|
|
() => ({
|
|
projectId: projectId.value,
|
|
filter: {
|
|
...threadFilters.value,
|
|
resourceIdString: resourceIdString.value
|
|
}
|
|
}),
|
|
{ keepPreviousResult: true }
|
|
)
|
|
|
|
const commentThreadsMetadata = computed(
|
|
() => viewerLoadedThreadsResult.value?.project?.commentThreads
|
|
)
|
|
const commentThreads = computed(() => commentThreadsMetadata.value?.items || [])
|
|
|
|
onViewerLoadedThreadsError((err) => {
|
|
// Show full page error only if serious error (core data couldn't be loaded)
|
|
const isWorkingLoad =
|
|
!!viewerLoadedThreadsResult.value?.project.commentThreads.items
|
|
if (isWorkingLoad) {
|
|
return
|
|
}
|
|
|
|
triggerNotification({
|
|
type: ToastNotificationType.Danger,
|
|
title: 'Comment loading failed',
|
|
description: `${err.message}`
|
|
})
|
|
logger.error(err)
|
|
})
|
|
|
|
onServerPrefetch(async () => {
|
|
await Promise.all([serverResourcesLoadedPromise.promise])
|
|
})
|
|
|
|
return {
|
|
objects,
|
|
commentThreads,
|
|
commentThreadsMetadata,
|
|
modelsAndVersionIds,
|
|
availableModelsAndVersions,
|
|
project,
|
|
resourceQueryVariables: computed(() => viewerLoadedResourcesVariables.value),
|
|
threadsQueryVariables: computed(() => threadsQueryVariables.value),
|
|
loadMoreVersions,
|
|
resourcesLoaded: computed(() => initLoadDone.value)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load resource responses (all of the relevant data from server)
|
|
*/
|
|
function setupResourceResponse(
|
|
state: InitialStateWithRequest
|
|
): InitialStateWithRequestAndResponse {
|
|
const resourceItemsData = setupResponseResourceItems(state)
|
|
const loadedResourceData = setupResponseResourceData(state, resourceItemsData)
|
|
|
|
return {
|
|
...state,
|
|
resources: {
|
|
request: {
|
|
...state.resources.request
|
|
},
|
|
response: {
|
|
...resourceItemsData,
|
|
...loadedResourceData
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupInterfaceState(
|
|
state: InitialStateWithUrlHashState
|
|
): InitialStateWithInterface {
|
|
// Is viewer busy - Using writable computed so that we can always intercept these calls
|
|
const isViewerBusy = ref(false)
|
|
const viewerBusy = computed({
|
|
get: () => isViewerBusy.value,
|
|
set: (newVal) => (isViewerBusy.value = !!newVal)
|
|
})
|
|
|
|
const isolatedObjectIds = ref([] as string[])
|
|
const hiddenObjectIds = ref([] as string[])
|
|
const selectedObjects = shallowRef<Raw<SpeckleObject>[]>([])
|
|
const propertyFilter = ref(null as Nullable<PropertyInfo>)
|
|
const isPropertyFilterApplied = ref(false)
|
|
const hasAnyFiltersApplied = computed(() => {
|
|
if (isolatedObjectIds.value.length) return true
|
|
if (hiddenObjectIds.value.length) return true
|
|
if (propertyFilter.value || isPropertyFilterApplied.value) return true
|
|
if (explodeFactor.value !== 0) return true
|
|
return false
|
|
})
|
|
|
|
const highlightedObjectIds = ref([] as string[])
|
|
const spotlightUserSessionId = ref(null as Nullable<string>)
|
|
|
|
const lightConfig = ref(DefaultLightConfiguration)
|
|
const explodeFactor = ref(0)
|
|
const selection = ref(null as Nullable<Vector3>)
|
|
|
|
const selectedObjectIds = computed(
|
|
() =>
|
|
new Set(
|
|
selectedObjects.value
|
|
.map((o) => o.id as MaybeNullOrUndefined<string>)
|
|
.filter(isNonNullable)
|
|
)
|
|
)
|
|
|
|
/**
|
|
* THREADS
|
|
*/
|
|
const { commentThreads, openThread, newThreadEditor } = setupViewerCommentBubbles({
|
|
state
|
|
})
|
|
const isTyping = ref(false)
|
|
const hideBubbles = ref(false)
|
|
|
|
/**
|
|
* Diffing
|
|
*/
|
|
const diffState = setupUiDiffState(state)
|
|
|
|
const position = ref(new Vector3())
|
|
const target = ref(new Vector3())
|
|
const isOrthoProjection = ref(false as boolean)
|
|
|
|
return {
|
|
...state,
|
|
ui: {
|
|
diff: {
|
|
...diffState
|
|
},
|
|
selection,
|
|
lightConfig,
|
|
explodeFactor,
|
|
spotlightUserSessionId,
|
|
viewerBusy,
|
|
threads: {
|
|
items: commentThreads,
|
|
openThread: {
|
|
thread: openThread,
|
|
isTyping,
|
|
newThreadEditor
|
|
},
|
|
hideBubbles
|
|
},
|
|
camera: {
|
|
// position: wrapRefWithTracking(position, 'position'),
|
|
// target: wrapRefWithTracking(target, 'target'),
|
|
position,
|
|
target,
|
|
isOrthoProjection
|
|
},
|
|
sectionBox: ref(null as Nullable<Box3>),
|
|
sectionBoxContext: {
|
|
visible: ref(false),
|
|
edited: ref(false)
|
|
},
|
|
filters: {
|
|
isolatedObjectIds,
|
|
hiddenObjectIds,
|
|
selectedObjects,
|
|
selectedObjectIds,
|
|
propertyFilter: {
|
|
filter: propertyFilter,
|
|
isApplied: isPropertyFilterApplied
|
|
},
|
|
hasAnyFiltersApplied
|
|
},
|
|
highlightedObjectIds,
|
|
measurement: {
|
|
enabled: ref(false),
|
|
options: ref<MeasurementOptions>({
|
|
visible: true,
|
|
type: MeasurementType.POINTTOPOINT,
|
|
units: 'm',
|
|
vertexSnap: true,
|
|
precision: 2
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type UseSetupViewerParams = { projectId: MaybeRef<string> }
|
|
|
|
export function useSetupViewer(params: UseSetupViewerParams): InjectableViewerState {
|
|
// Initialize full state object - each subsequent state initialization depends on
|
|
// the results of the previous ones until we have the final full object
|
|
const initState = setupInitialState(params)
|
|
const initialStateWithRequest = setupResourceRequest(initState)
|
|
const stateWithResources = setupResourceResponse(initialStateWithRequest)
|
|
const state: InjectableViewerState = setupInterfaceState(stateWithResources)
|
|
|
|
// We don't want the state to ever be proxified (e.g. when passed through props),
|
|
// cause that will break composables (refs will be automatically unwrapped as if
|
|
// they're accessed in a template)
|
|
const rawState = markRaw(state)
|
|
|
|
// Inject it into descendant components
|
|
provide(InjectableViewerStateKey, rawState)
|
|
|
|
return rawState
|
|
}
|
|
|
|
/**
|
|
* COMPOSABLES FOR RETRIEVING (PARTS OF) INJECTABLE STATE
|
|
*/
|
|
|
|
export function useInjectedViewerState(): InjectableViewerState {
|
|
// we're forcing TS to ignore the scenario where this data can't be found and returns undefined
|
|
// to avoid unnecessary null checks everywhere
|
|
const state = inject(InjectableViewerStateKey) as InjectableViewerState
|
|
return state
|
|
}
|
|
|
|
export function useInjectedViewer(): InjectableViewerState['viewer'] {
|
|
const { viewer } = useInjectedViewerState()
|
|
return viewer
|
|
}
|
|
|
|
export function useInjectedViewerLoadedResources(): InjectableViewerState['resources']['response'] {
|
|
const { resources } = useInjectedViewerState()
|
|
return resources.response
|
|
}
|
|
|
|
export function useInjectedViewerRequestedResources(): InjectableViewerState['resources']['request'] {
|
|
const { resources } = useInjectedViewerState()
|
|
return resources.request
|
|
}
|
|
|
|
export function useInjectedViewerInterfaceState(): InjectableViewerState['ui'] {
|
|
const { ui } = useInjectedViewerState()
|
|
return ui
|
|
}
|
|
|
|
export function useResetUiState() {
|
|
const {
|
|
ui: { camera, sectionBox, highlightedObjectIds, lightConfig }
|
|
} = useInjectedViewerState()
|
|
const { resetFilters } = useFilterUtilities()
|
|
const { endDiff } = useDiffUtilities()
|
|
|
|
return () => {
|
|
camera.isOrthoProjection.value = false
|
|
sectionBox.value = null
|
|
highlightedObjectIds.value = []
|
|
lightConfig.value = { ...DefaultLightConfiguration }
|
|
resetFilters()
|
|
endDiff()
|
|
}
|
|
}
|
|
|
|
export { InjectableViewerStateKey, useSetupViewerScope }
|