feat(fe2): view modes stored in saved views (and elsewhere) (#5320)

* feat(fe2): view modes stored in saved views (and elsewhere)

* lint fixes
This commit is contained in:
Kristaps Fabians Geikins
2025-08-28 11:40:58 +03:00
committed by GitHub
parent bb29033508
commit 8dbd342a40
13 changed files with 270 additions and 125 deletions
@@ -93,7 +93,10 @@ const {
const { getActiveMeasurement, removeMeasurement, enableMeasurements, hasMeasurements } =
useMeasurementUtilities()
const { resetExplode } = useFilterUtilities()
const { currentViewMode, setViewMode } = useViewModeUtilities()
const {
viewMode: { mode: currentViewMode },
setViewMode
} = useViewModeUtilities()
const {
ui: { explodeFactor }
} = useInjectedViewerState()
@@ -68,7 +68,9 @@ import { useViewModeUtilities } from '~/lib/viewer/composables/ui'
import { TIME_MS } from '@speckle/shared'
const mp = useMixpanel()
const { currentViewMode } = useViewModeUtilities()
const {
viewMode: { mode: currentViewMode }
} = useViewModeUtilities()
const isLightingSupported = computed(() => {
const supported = currentViewMode.value === ViewMode.DEFAULT
@@ -95,19 +95,15 @@ import { ViewMode } from '@speckle/viewer'
import { useViewModeUtilities } from '~~/lib/viewer/composables/ui'
import { ViewModeShortcuts } from '~/lib/viewer/helpers/shortcuts/shortcuts'
import { FormSwitch } from '@speckle/ui-components'
import { useTheme } from '~/lib/core/composables/theme'
import { defaultEdgeColorValue } from '~/lib/viewer/composables/setup/viewMode'
const {
setViewMode,
currentViewMode,
edgesEnabled,
toggleEdgesEnabled,
setEdgesWeight,
edgesWeight,
setEdgesColor,
edgesColor
viewMode: { edgesColor, edgesWeight, edgesEnabled, mode: currentViewMode }
} = useViewModeUtilities()
const { isLightTheme } = useTheme()
const showSettings = ref(false)
@@ -115,14 +111,17 @@ const isActiveMode = (mode: ViewMode) => mode === currentViewMode.value
const viewModeShortcuts = Object.values(ViewModeShortcuts)
const edgesColorOptions = computed(() => [
isLightTheme.value || currentViewMode.value !== ViewMode.PEN ? 0x1a1a1a : 0xffffff, // black or white
0x3b82f6, // blue-500
0x8b5cf6, // violet-500
0x65a30d, // lime-600
0xf97316, // orange-500
0xf43f5e //rose-500
])
const edgesColorOptions = computed(
() =>
[
defaultEdgeColorValue, // black or white
0x3b82f6, // blue-500
0x8b5cf6, // violet-500
0x65a30d, // lime-600
0xf97316, // orange-500
0xf43f5e //rose-500
] as const
)
const handleViewModeChange = (mode: ViewMode) => {
setViewMode(mode)
@@ -2,7 +2,7 @@ import {
useInjectedViewerState,
useResetUiState
} from '~~/lib/viewer/composables/setup'
import { SpeckleViewer, TimeoutError } from '@speckle/shared'
import { isUndefinedOrVoid, SpeckleViewer, TimeoutError } from '@speckle/shared'
import { get } from 'lodash-es'
import { Vector3 } from 'three'
import {
@@ -10,7 +10,7 @@ import {
useFilterUtilities,
useSelectionUtilities
} from '~~/lib/viewer/composables/ui'
import { CameraController, ViewMode, VisualDiffMode } from '@speckle/viewer'
import { CameraController, VisualDiffMode } from '@speckle/viewer'
import type { NumericPropertyInfo } from '@speckle/viewer'
import type { Merge, PartialDeep } from 'type-fest'
import type { SectionBoxData } from '@speckle/shared/viewer/state'
@@ -117,7 +117,13 @@ export function useStateSerialization() {
isOrthoProjection: state.ui.camera.isOrthoProjection.value,
zoom: (get(camControls, '_zoom') as unknown as number) || 1 // kinda hacky, _zoom is a protected prop
},
viewMode: state.ui.viewMode.value,
viewMode: {
mode: state.ui.viewMode.mode.value,
edgesEnabled: state.ui.viewMode.edgesEnabled.value,
edgesWeight: state.ui.viewMode.edgesWeight.value,
outlineOpacity: state.ui.viewMode.outlineOpacity.value,
edgesColor: state.ui.viewMode.edgesColor.value
},
sectionBox: state.ui.sectionBox.value ? box : null,
lightConfig: { ...state.ui.lightConfig.value },
explodeFactor: state.ui.explodeFactor.value,
@@ -361,11 +367,16 @@ export function useApplySerializedState() {
}
// Restore view mode
if (state.ui?.viewMode) {
viewMode.value = state.ui.viewMode
} else {
viewMode.value = ViewMode.DEFAULT
}
if (!isUndefinedOrVoid(state.ui?.viewMode?.mode))
viewMode.mode.value = state.ui!.viewMode!.mode
if (!isUndefinedOrVoid(state.ui?.viewMode?.edgesEnabled))
viewMode.edgesEnabled.value = state.ui!.viewMode!.edgesEnabled
if (!isUndefinedOrVoid(state.ui?.viewMode?.edgesWeight))
viewMode.edgesWeight.value = state.ui!.viewMode!.edgesWeight
if (!isUndefinedOrVoid(state.ui?.viewMode?.outlineOpacity))
viewMode.outlineOpacity.value = state.ui!.viewMode!.outlineOpacity
if (!isUndefinedOrVoid(state.ui?.viewMode?.edgesColor))
viewMode.edgesColor.value = state.ui!.viewMode!.edgesColor
explodeFactor.value = state.ui?.explodeFactor || 0
lightConfig.value = {
@@ -6,17 +6,17 @@ import {
MeasurementType,
FilteringExtension
} from '@speckle/viewer'
import {
type FilteringState,
type PropertyInfo,
type SunLightConfiguration,
type SpeckleView,
type MeasurementOptions,
type DiffResult,
type Viewer,
type WorldTree,
type VisualDiffMode,
ViewMode
import type {
ViewMode,
FilteringState,
PropertyInfo,
SunLightConfiguration,
SpeckleView,
MeasurementOptions,
DiffResult,
Viewer,
WorldTree,
VisualDiffMode
} from '@speckle/viewer'
import { inject, ref, provide } from 'vue'
import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue'
@@ -85,6 +85,8 @@ import {
useBuildSavedViewsUIState,
type SavedViewsUIState
} from '~/lib/viewer/composables/savedViews/state'
import type { defaultEdgeColorValue } from '~/lib/viewer/composables/setup/viewMode'
import { useViewModesSetup } from '~/lib/viewer/composables/setup/viewMode'
export type LoadedModel = NonNullable<
Get<ViewerLoadedResourcesQuery, 'project.models.items[0]'>
@@ -313,7 +315,16 @@ export type InjectableViewerState = Readonly<{
target: Ref<Vector3>
isOrthoProjection: Ref<boolean>
}
viewMode: Ref<ViewMode>
viewMode: {
mode: Ref<ViewMode>
edgesEnabled: Ref<boolean>
edgesWeight: Ref<number>
outlineOpacity: Ref<number>
edgesColor: Ref<typeof defaultEdgeColorValue | number>
finalEdgesColor: ComputedRef<number>
defaultEdgesColor: ComputedRef<number>
resetViewMode: () => void
}
diff: {
newVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
oldVersion: ComputedRef<ViewerModelVersionCardItemFragment | undefined>
@@ -1104,7 +1115,7 @@ function setupInterfaceState(
if (propertyFilter.value || isPropertyFilterApplied.value) return true
return false
})
const viewMode = ref<ViewMode>(ViewMode.DEFAULT)
const { viewMode } = useViewModesSetup()
const highlightedObjectIds = ref([] as string[])
const spotlightUserSessionId = ref(null as Nullable<string>)
@@ -1143,6 +1154,7 @@ function setupInterfaceState(
return {
...state,
ui: {
viewMode,
diff: {
...diffState
},
@@ -1168,7 +1180,6 @@ function setupInterfaceState(
target,
isOrthoProjection
},
viewMode,
sectionBox: ref(null as Nullable<SectionBoxData>),
sectionBoxContext: {
visible: ref(false),
@@ -1263,7 +1274,7 @@ export function useResetUiState() {
sectionBox.value = null
highlightedObjectIds.value = []
lightConfig.value = { ...DefaultLightConfiguration }
viewMode.value = ViewMode.DEFAULT
viewMode.resetViewMode()
resetFilters()
endDiff()
}
@@ -42,12 +42,18 @@ function useDebugViewer() {
// Get current viewer state
window.VIEWER_STATE = () => fullViewerState
// Get serialized version of current state
// Get serialized version of current state as string
window.VIEWER_SERIALIZED_STATE = (...args: Parameters<typeof serialize>) => {
const serialized = serialize(...args)
return JSON.stringify(serialized)
}
// Get serialized version of current state as object
window.VIEWER_SERIALIZED_STATE_OBJECT = (...args: Parameters<typeof serialize>) => {
const serialized = serialize(...args)
return serialized
}
// Apply viewer state
window.APPLY_VIEWER_STATE = (
state: SpeckleViewer.ViewerState.SerializedViewerState
@@ -54,8 +54,7 @@ import { areVectorsLooselyEqual } from '~~/lib/viewer/helpers/three'
import { SafeLocalStorage, type Nullable } from '@speckle/shared'
import {
useCameraUtilities,
useMeasurementUtilities,
useViewModeUtilities
useMeasurementUtilities
} from '~~/lib/viewer/composables/ui'
import { setupDebugMode } from '~~/lib/viewer/composables/setup/dev'
import { useEmbed } from '~/lib/viewer/composables/setup/embed'
@@ -64,6 +63,7 @@ import type { SectionBoxData } from '@speckle/shared/viewer/state'
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'
function useViewerLoadCompleteEventHandler() {
const state = useInjectedViewerState()
@@ -977,14 +977,6 @@ function useViewerCursorIntegration() {
})
}
function useViewerViewModesIntegration() {
const { resetViewMode } = useViewModeUtilities()
onBeforeUnmount(() => {
resetViewMode()
})
}
export function useViewerPostSetup() {
if (import.meta.server) return
useViewerObjectAutoLoading()
@@ -1005,6 +997,6 @@ export function useViewerPostSetup() {
useDisableZoomOnEmbed()
useViewerCursorIntegration()
useViewerTreeIntegration()
useViewerViewModesIntegration()
useViewModesPostSetup()
setupDebugMode()
}
@@ -0,0 +1,127 @@
import { defaultViewModeEdgeColorValue } from '@speckle/shared/viewer/state'
import { ViewMode, ViewModes } from '@speckle/viewer'
import { watchTriggerable } from '@vueuse/core'
import { useTheme } from '~/lib/core/composables/theme'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
import { useOnViewerLoadComplete } from '~/lib/viewer/composables/viewer'
export const defaultEdgeColorValue = defaultViewModeEdgeColorValue
export const edgeColorDark = 0x1a1a1a
export const edgeColorLight = 0xffffff
export const useViewModesSetup = () => {
const { isLightTheme } = useTheme()
const mode = ref<ViewMode>(ViewMode.DEFAULT)
const edgesEnabled = ref(true)
const edgesWeight = ref(1)
const outlineOpacity = ref(0.75)
const edgesColor = ref<typeof defaultEdgeColorValue | number>(defaultEdgeColorValue)
const defaultEdgesColor = computed(() => {
if (mode.value === ViewMode.PEN) {
return isLightTheme.value ? edgeColorDark : edgeColorLight
} else {
return isLightTheme.value ? edgeColorLight : edgeColorDark
}
})
const finalEdgesColor = computed(() => {
if (edgesColor.value !== defaultEdgeColorValue) return edgesColor.value
return defaultEdgesColor.value
})
const resetViewMode = () => {
mode.value = ViewMode.DEFAULT
edgesEnabled.value = true
edgesWeight.value = 1
outlineOpacity.value = 0.75
edgesColor.value = defaultEdgeColorValue
}
return {
viewMode: {
mode,
edgesEnabled,
edgesWeight,
outlineOpacity,
edgesColor,
finalEdgesColor,
defaultEdgesColor,
resetViewMode
}
}
}
export const useViewModesPostSetup = () => {
const {
ui: { viewMode },
viewer: { instance }
} = useInjectedViewerState()
const {
mode,
edgesEnabled,
edgesWeight,
outlineOpacity,
finalEdgesColor,
resetViewMode
} = viewMode
const updateViewMode = () => {
const viewModes = instance.getExtension(ViewModes)
if (viewModes) {
viewModes.setViewMode(mode.value, {
edges: edgesEnabled.value,
outlineThickness: edgesWeight.value,
outlineOpacity: outlineOpacity.value,
outlineColor: finalEdgesColor.value
})
}
}
// state -> viewer
useOnViewerLoadComplete(
() => {
updateViewMode()
},
{ initialOnly: true }
)
const { ignoreUpdates: ignoreEdgesEnabledUpdates } = watchTriggerable(
edgesEnabled,
(newVal, oldVal) => {
if (oldVal === newVal) return
updateViewMode()
}
)
watchTriggerable(edgesWeight, (newVal, oldVal) => {
if (oldVal === newVal) return
updateViewMode()
})
const { ignoreUpdates: ignoreOutlineOpacityUpdates } = watchTriggerable(
outlineOpacity,
(newVal, oldVal) => {
if (oldVal === newVal) return
updateViewMode()
}
)
watchTriggerable(finalEdgesColor, (newVal, oldVal) => {
if (oldVal === newVal) return
updateViewMode()
})
watchTriggerable(mode, (newVal, oldVal) => {
if (oldVal === newVal) return
if (newVal === ViewMode.PEN) {
ignoreOutlineOpacityUpdates(() => (outlineOpacity.value = 1))
ignoreEdgesEnabledUpdates(() => (edgesEnabled.value = true))
} else {
ignoreOutlineOpacityUpdates(() => (outlineOpacity.value = 0.75))
}
updateViewMode()
})
onBeforeUnmount(() => {
resetViewMode()
})
}
@@ -1,11 +1,11 @@
import { SpeckleViewer, TIME_MS, timeoutAt } from '@speckle/shared'
import {
type TreeNode,
type MeasurementOptions,
type PropertyInfo,
import type {
TreeNode,
MeasurementOptions,
PropertyInfo,
ViewMode
} from '@speckle/viewer'
import { MeasurementsExtension, ViewModes, MeasurementEvent } from '@speckle/viewer'
import { MeasurementsExtension, MeasurementEvent } from '@speckle/viewer'
import { until } from '@vueuse/shared'
import { useActiveElement } from '@vueuse/core'
import { difference, isString, uniq } from 'lodash-es'
@@ -25,9 +25,9 @@ import type {
ViewerShortcut,
ViewerShortcutAction
} from '~/lib/viewer/helpers/shortcuts/types'
import { useTheme } from '~/lib/core/composables/theme'
import { useMixpanel } from '~/lib/core/composables/mp'
import { isStringPropertyInfo } from '~/lib/viewer/helpers/sceneExplorer'
import type { defaultEdgeColorValue } from '~/lib/viewer/composables/setup/viewMode'
export function useSectionBoxUtilities() {
const { instance } = useInjectedViewer()
@@ -754,49 +754,11 @@ export function useHighlightedObjectsUtilities() {
}
export function useViewModeUtilities() {
const { instance } = useInjectedViewer()
const { viewMode } = useInjectedViewerInterfaceState()
const { isLightTheme } = useTheme()
const mp = useMixpanel()
const edgesEnabled = ref(true)
const edgesWeight = ref(1)
const outlineOpacity = ref(0.75)
const defaultColor = ref(0x1a1a1a)
const edgesColor = ref(defaultColor.value)
const currentViewMode = computed(() => viewMode.value)
const updateViewMode = () => {
const viewModes = instance.getExtension(ViewModes)
if (viewModes) {
viewModes.setViewMode(currentViewMode.value, {
edges: edgesEnabled.value,
outlineThickness: edgesWeight.value,
outlineOpacity: outlineOpacity.value,
outlineColor: edgesColor.value
})
}
}
const setViewMode = (mode: ViewMode) => {
viewMode.value = mode
if (mode === ViewMode.PEN) {
outlineOpacity.value = 1
edgesEnabled.value = true
if (edgesColor.value === defaultColor.value) {
if (!isLightTheme.value) {
edgesColor.value = 0xffffff
}
}
} else {
outlineOpacity.value = 0.75
if (edgesColor.value === 0xffffff) {
edgesColor.value = isLightTheme.value ? 0xffffff : defaultColor.value
}
}
updateViewMode()
viewMode.mode.value = mode
mp.track('Viewer Action', {
type: 'action',
name: 'set-view-mode',
@@ -805,28 +767,25 @@ export function useViewModeUtilities() {
}
const toggleEdgesEnabled = () => {
edgesEnabled.value = !edgesEnabled.value
updateViewMode()
viewMode.edgesEnabled.value = !viewMode.edgesEnabled.value
mp.track('Viewer Action', {
type: 'action',
name: 'toggle-edges',
enabled: edgesEnabled.value
enabled: viewMode.edgesEnabled.value
})
}
const setEdgesWeight = (weight: number) => {
edgesWeight.value = Number(weight)
updateViewMode()
viewMode.edgesWeight.value = Number(weight)
mp.track('Viewer Action', {
type: 'action',
name: 'set-edges-weight',
weight: edgesWeight.value
weight: viewMode.edgesWeight.value
})
}
const setEdgesColor = (color: number) => {
edgesColor.value = color
updateViewMode()
const setEdgesColor = (color: number | typeof defaultEdgeColorValue) => {
viewMode.edgesColor.value = color
mp.track('Viewer Action', {
type: 'action',
name: 'set-edges-color',
@@ -834,24 +793,13 @@ export function useViewModeUtilities() {
})
}
const resetViewMode = () => {
setViewMode(ViewMode.DEFAULT)
edgesEnabled.value = true
edgesWeight.value = 1
outlineOpacity.value = 0.75
edgesColor.value = defaultColor.value
}
return {
currentViewMode,
viewMode,
setViewMode,
edgesEnabled,
toggleEdgesEnabled,
edgesWeight,
setEdgesWeight,
setEdgesColor,
edgesColor,
resetViewMode
resetViewMode: viewMode.resetViewMode
}
}
+1
View File
@@ -14,6 +14,7 @@ declare global {
VIEWER?: any
VIEWER_STATE?: any
VIEWER_SERIALIZED_STATE?: any
VIEWER_SERIALIZED_STATE_OBJECT?: any
APPLY_VIEWER_STATE?: any
APPLY_VIEWER_DD_EVENT?: any
}
@@ -148,7 +148,13 @@ export const convertLegacyDataToStateFactory =
isOrthoProjection: !!data.camPos?.[6],
zoom: data.camPos?.[7] || 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0.75,
edgesWeight: 1
},
sectionBox: sectionBox
? {
min: (sectionBox.min as number[]) || [0, 0, 0],
@@ -43,7 +43,13 @@ describe('Viewer State helpers', () => {
isOrthoProjection: false,
zoom: 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0,
edgesWeight: 0
},
sectionBox: null,
lightConfig: {},
explodeFactor: 0,
@@ -149,7 +155,13 @@ describe('Viewer State helpers', () => {
isOrthoProjection: false,
zoom: 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0,
edgesWeight: 0
},
sectionBox: null,
lightConfig: {},
explodeFactor: 0,
@@ -202,7 +214,13 @@ describe('Viewer State helpers', () => {
isOrthoProjection: false,
zoom: 1
},
viewMode: 0,
viewMode: {
mode: 0,
edgesColor: 0,
edgesEnabled: true,
outlineOpacity: 0,
edgesWeight: 0
},
sectionBox: null,
lightConfig: {},
explodeFactor: 0,
+24 -3
View File
@@ -1,9 +1,11 @@
import { has, intersection, isObjectLike } from '#lodash'
import { has, intersection, isNumber, isObjectLike } from '#lodash'
import type { MaybeNullOrUndefined, Nullable } from '../../core/helpers/utilityTypes.js'
import type { PartialDeep } from 'type-fest'
import { UnformattableSerializedViewerStateError } from '../errors/index.js'
import { coerceUndefinedValuesToNull } from '../../core/index.js'
export const defaultViewModeEdgeColorValue = 'DEFAULT_EDGE_COLOR'
/** Redefining these is unfortunate. Especially since they are not part of viewer-core */
enum MeasurementType {
PERPENDICULAR = 0,
@@ -35,6 +37,9 @@ export interface SectionBoxData {
* - ui.diff added
* v1.2 -> v1.3
* - ui.filters.selectedObjectIds removed in favor of ui.filters.selectedObjectApplicationIds
* v1.3 -> 1.4
* - ui.viewMode -> ui.viewMode.mode
* - ui.viewMode has new keys: edgesEnabled, edgesWeight, outlineOpacity, edgesColor
*/
export const SERIALIZED_VIEWER_STATE_VERSION = 1.3
@@ -88,7 +93,13 @@ export type SerializedViewerState = {
isOrthoProjection: boolean
zoom: number
}
viewMode: number
viewMode: {
mode: number
edgesEnabled: boolean
edgesWeight: number
outlineOpacity: number
edgesColor: typeof defaultViewModeEdgeColorValue | number
}
sectionBox: Nullable<SectionBoxData>
lightConfig: {
intensity?: number
@@ -174,6 +185,10 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
)
}
const viewMode = isNumber(state.ui?.viewMode)
? state.ui.viewMode
: state.ui?.viewMode?.mode
return {
projectId: state.projectId || throwInvalidError('projectId'),
sessionId: state.sessionId || `nullSessionId-${Math.random() * 1000}`,
@@ -236,7 +251,13 @@ const initializeMissingData = (state: UnformattedState): SerializedViewerState =
isOrthoProjection: state.ui?.camera?.isOrthoProjection || false,
zoom: state.ui?.camera?.zoom || 1
},
viewMode: state.ui?.viewMode || 0,
viewMode: {
mode: viewMode || 0,
edgesEnabled: state.ui?.viewMode?.edgesEnabled || false,
edgesWeight: state.ui?.viewMode?.edgesWeight || 1,
outlineOpacity: state.ui?.viewMode?.outlineOpacity || 0.75,
edgesColor: state.ui?.viewMode?.edgesColor || defaultViewModeEdgeColorValue
},
sectionBox:
state.ui?.sectionBox?.min?.length && state.ui?.sectionBox.max?.length
? // Complains otherwise